# Referencia de la API REST de Probara

> Referencia OpenAPI 3.1 de la API de integración Probara bajo /api/v1.

Contrato legible por máquinas para integradores: **OpenAPI 3.1** (`/openapi/v1.json`), generado desde esquemas Zod en `@tcms/shared` y verificado en CI.

Usa la [referencia interactiva v1](https://docs.probara.net/es/developers/api-reference/v1/) para esquemas por operación, cuerpos de petición y ejemplos de respuesta.

## URL base

Todas las rutas de integración usan el prefijo **`/api/v1`**.

| Entorno      | URL base                  |
| ------------ | ------------------------- |
| Producción   | `https://app.probara.net` |
| Worker local | `http://localhost:8787`   |

Ejemplo:

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

Rutas sin versión (`/api/...`) responden `404 not_found`.

## Autenticación

Usa un [token API por organización](https://docs.probara.net/es/developers/authentication/) como `Authorization: Bearer <secreto>`. Con un token válido no hace falta `X-Organization-Id`. Para enviar resultados desde CI, usa en su lugar un [token de aplicación](https://docs.probara.net/es/developers/authentication/#tokens-de-aplicación): pertenece a la organización y solo puede hacer las [llamadas que hacen los reporters de CI](https://docs.probara.net/es/developers/authentication/#llamadas-permitidas) — la CLI de Probara, que también sincroniza archivos `.feature`, y los reporters de frameworks.

Las rutas solo de sesión (`/api/v1/auth/*`, `/api/v1/me/api-tokens`, aceptar invitación con cookie) están en la guía de autenticación, no en el documento OpenAPI.

## Errores

Las peticiones fallidas devuelven JSON:

```json
{
  "error": {
    "code": "not_found",
    "message": "Mensaje legible",
    "details": {}
  }
}
```

Códigos habituales: `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` es el único de estos que no habla de la petición que enviaste. Una organización
que tiene más proyectos de los que su plan permite mantiene abiertos solo los creados más
recientemente; todos los demás quedan bloqueados, y cualquier lectura o escritura sobre ellos
responde `403 { code: "project_locked" }` — por esta API y por MCP, tanto con una sesión como con
una clave de API. `DELETE /api/v1/projects/{projectId}` es la única operación que un proyecto
bloqueado sigue sirviendo, porque eliminar es la forma de que la organización vuelva a estar
dentro del límite. El bloqueo no elimina nada: `GET /api/v1/projects` sigue listando el proyecto,
con un `lockedAt` no nulo, y subir el límite de la organización borra la marca y lo reabre sin
cambios. No lo reintentes: solo se libera cuando una persona actúa.

`test_case_synced_read_only` indica que el caso está sincronizado desde un archivo `.feature` de Git. Su escenario es de solo lectura en Probara, así que una escritura que lo cambiaría responde `409` con este código y no escribe nada. Cambia el escenario en el archivo: consulta [Casos sincronizados](#casos-sincronizados).

`organization_pending_deletion` indica que la eliminación de la organización está programada: la
solicitó una persona propietaria o el soporte de Probara, o ningún miembro abrió la organización en la
aplicación web durante 12 meses. Durante los 14 días de gracia, cualquier escritura responde
`403 { code: "organization_pending_deletion" }` — por esta API y por MCP, con una clave de API igual
que con una sesión —, mientras que todas las lecturas, incluida la exportación, siguen funcionando. La
petición no tiene nada mal y reintentarla no sirve: el estado solo termina cuando se cancela la
eliminación o cuando la organización se borra. Ninguna llamada por API o MCP la cancela. Una
eliminación programada por inactividad se cancela cuando un miembro abre la organización en la
aplicación web, y a partir de ahí sus escrituras se aceptan; las claves de API y los tokens OAuth no
cuentan como actividad, aunque se estén usando. `GET /api/v1/orgs/current` informa el estado como
`pendingDeletion: { requestedAt, scheduledFor }` (`null` en otro caso). Consulta
[Eliminar una organización](https://docs.probara.net/es/guides/delete-organization/).

Un enlace público de una ejecución que pertenece a un proyecto bloqueado responde exactamente el
mismo `404 { code: "not_found" }`, byte a byte, que un token desconocido. Es deliberado: quien
tiene un enlace válido no debe poder usarlo para deducir que la organización supera su límite.

`details` a veces incluye su propio `code`, distinto del de nivel superior — por ejemplo, un
rechazo `validation_failed` de un marcado incluye `details: { code: "elapsed_ms_required", path:
["elapsedMs"] }`. Usa siempre primero el `code` de nivel superior para decidir la rama; trata
`details.code` como información complementaria, nunca como un reemplazo.

## Paginación

Los listados de tabla (proyectos, hitos, ejecuciones, defectos, miembros de la organización, invitaciones, grupos de usuarios, campos personalizados y el listado de casos archivados descrito más abajo) usan **paginación por página**. Aceptan `page` (base 1, por defecto `1`) y `pageSize` (por defecto `50`, máximo `200`). Valores fuera de rango o no enteros devuelven `422 validation_failed`. La respuesta incluye la página, su tamaño y el total de filas que coinciden con los filtros y la búsqueda activos — no solo las de la página:

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

Una `page` posterior a la última devuelve `200` con `items: []` y el `total` real. Muchos listados aceptan además un parámetro `q` para búsqueda por subcadena sin distinción de mayúsculas; `q` se aplica antes de paginar y se refleja en `total`.

**Paginación por cursor (feeds y árboles).** Los endpoints de feed cronológico (eventos de auditoría y sus variantes por ejecución/defecto, y el historial de versiones de un caso) y los endpoints del árbol de suites/casos (incluido el listado activo por defecto `GET /api/v1/projects/{projectId}/test-cases` y su filtro `archived=true`) mantienen la paginación por cursor: `limit` (por defecto 50, máximo 200) y `cursor` opcional (ULID), con respuesta `{ "items": [], "nextCursor": "01ARZ3…" }`. Si `nextCursor` es `null`, no hay más páginas. La única excepción es el endpoint **por página** `GET /api/v1/projects/{projectId}/test-cases/archived` descrito abajo, que existe justamente para navegar los casos archivados por página numerada (Papelera) en lugar de recorrerlos como feed por cursor.

## Límites de peticiones

Las peticiones autenticadas con un token de API, un token de app o un token de acceso OAuth cuentan contra el límite del plan de tu organización: un número de peticiones por ventana de 60 segundos, compartido por todas las credenciales de la organización. Las peticiones de la aplicación web (cookie de sesión) nunca cuentan.

Cada respuesta contada indica el límite y lo que queda de él, con los campos del borrador IETF de [campos de cabecera RateLimit](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` es el límite y `w` la ventana, en segundos. `r` es el número de peticiones que quedan y `t` los segundos hasta que la ventana se reinicia. Pasado el límite, la petición devuelve `429 too_many_requests` con `RateLimit: "org";r=0;t=…` y una cabecera `Retry-After` con los mismos segundos: espera ese tiempo y vuelve a enviar la petición. Regula tu cliente con `r` y `t` para no llegar al `429`.

## Versionado

Los cambios incompatibles requieren una ruta mayor nueva (por ejemplo `/api/v2`). `/api/v1` se mantiene hasta deprecación explícita.

## Campos de texto enriquecido

El texto largo es texto enriquecido: las descripciones de casos de prueba, suites, proyectos, hitos, planes, entornos, ejecuciones y defectos; `action`, `data` y `expected` de un paso de caso, y el resultado real de un paso; los comentarios; las notas de resultado; las notas de revisión; y los valores de campos personalizados de párrafo, incluidas las precondiciones y las postcondiciones. Los títulos, nombres, etiquetas y otros valores cortos siguen siendo texto plano, y también los pasos Gherkin.

Cada campo de texto enriquecido tiene dos formas, y toda respuesta incluye ambas:

- `<campo>` es el texto en **Markdown**. Las integraciones, las herramientas MCP, las importaciones y las exportaciones usan esta forma.
- `<campo>Document` es el mismo texto como **documento**: un árbol JSON cuyo `attrs.version` es hoy `1`. La aplicación web edita esta forma y conserva cada detalle de formato.

Un campo vacío responde `null` en ambas formas.

```json
{
  "description": "Abre el **carrito** y revisa el total.\n\n![Carrito](attachment:01J...IMG)",
  "descriptionDocument": {
    "type": "doc",
    "attrs": { "version": 1 },
    "content": [
      {
        "type": "paragraph",
        "content": [
          { "type": "text", "text": "Abre el " },
          { "type": "text", "text": "carrito", "marks": [{ "type": "bold" }] },
          { "type": "text", "text": " y revisa el total." }
        ]
      },
      {
        "type": "image",
        "attrs": { "src": "attachment:01J...IMG", "alt": "Carrito", "width": 800, "height": 600 }
      }
    ]
  }
}
```

### Escribir un campo

Envía una forma por campo: `<campo>` o `<campo>Document`. Enviar ambas responde `422 validation_failed`, y el mensaje nombra el par, por ejemplo `send either description or descriptionDocument, not both`. Los proyectos, las suites y las notas de revisión también devuelven `both_inputs` en `details.code`. Envía `null` en cualquiera de las dos formas para borrar el campo.

Los límites cuentan el texto plano del campo, es decir, el texto sin formato ni referencias a imágenes:

- Descripciones de casos de prueba, de valores de campos personalizados de párrafo y de defectos: 4000 caracteres.
- Descripciones de ejecuciones y notas de revisión: 2000 caracteres.
- Comentarios: 5000 caracteres.
- Resultado real de un paso: 20000 caracteres.

Cada documento tiene un límite de 256 KiB, y también el Markdown enviado en `<campo>`, que se comprueba antes de analizarlo. Un valor mayor responde `422` con `details.code` `too_large` y `details.limit` `262144`.

Un valor rechazado responde `422 validation_failed`. `details.code` indica la regla que incumple, y `details.field` indica el campo:

| `details.code`         | Significado                                                                                                                                                 |
| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `invalid_document`     | El documento no cumple el esquema, o una mención no es un ULID de usuario.                                                                                  |
| `unsupported_version`  | `attrs.version` no es una versión que esta API lea.                                                                                                         |
| `unsupported_link`     | Un enlace usa un protocolo distinto de `http`, `https` o `mailto`. En Markdown se conserva el texto del enlace y se quita el enlace en lugar de rechazarlo. |
| `unsupported_image`    | El origen de una imagen no es `attachment:<ULID>`.                                                                                                          |
| `mentions_not_allowed` | El campo no admite menciones. Solo las admiten los comentarios.                                                                                             |
| `too_long`             | El texto plano supera el límite del campo (`details.limit`).                                                                                                |
| `too_large`            | El documento o el Markdown supera 256 KiB (`details.limit`).                                                                                                |
| `unknown_image`        | El texto nombra una imagen que nunca se preparó o que expiró. `details.ulids` las enumera, y `details.field` no aparece.                                    |

Los encabezados más profundos que el nivel 3 se leen como nivel 3. Un campo admite párrafos, encabezados, negrita, cursiva, tachado, código en línea, enlaces, listas con viñetas, numeradas y de tareas, citas en bloque, bloques de código, tablas e imágenes.

### Imágenes

Las imágenes van insertadas en el texto. Prepara el archivo primero y luego guarda el campo que lo usa:

1. Prepara cada archivo con `POST /api/v1/images:stage`: `multipart/form-data` con una o más partes `file`, como máximo 20 por petición. Quien llama necesita el permiso `attachments.execute`, que tienen los miembros, los administradores y los propietarios. Cada archivo debe ser PNG, JPEG o WebP, de como máximo 10 MiB, y ningún lado puede superar 8192 píxeles. La respuesta `201` enumera cada imagen con su `ulid` y un `previewUrl`.
2. Referencia la imagen en el texto: en Markdown `![texto alternativo](attachment:<ULID>)`, o en un documento, un nodo `image` cuyo `attrs.src` sea `attachment:<ULID>`. Cualquier otro origen se rechaza con `unsupported_image`.
3. Guarda el campo. El guardado copia el archivo preparado a su copia permanente, reducida para que su lado mayor mida como máximo 2048 píxeles y almacenada como WebP, y el campo la retiene. Una imagen sigue almacenada mientras cualquier texto o revisión guardada la referencie. El guardado que quita su última referencia la elimina. Una imagen preparada que ningún guardado referencia se elimina después de unas 48 horas.

Solo los comentarios admiten menciones: `@[ulid]` en Markdown, o un nodo `mention` con `attrs.id` en un documento. Consulta [Comentarios de casos de prueba](https://docs.probara.net/es/developers/api/test-case-comments/#menciones).

### Dónde va el texto

Los webhooks, los correos de notificación y el feed de actividad llevan extractos de texto plano, nunca Markdown ni documentos. Los cuerpos de los issues de GitHub son Markdown, y cada imagen apunta a su URL de recurso almacenada. Las descripciones y los comentarios de Jira se convierten a Atlassian Document Format: las imágenes pasan a ser enlaces, y las menciones pasan a ser `@Nombre`. Los comentarios entrantes de Jira conservan su formato, y se descartan sus archivos multimedia.

## Búsqueda por display ID

Las suites y los casos exponen **display IDs** estables en las respuestas (`displayId`, más `suiteNumber` / `caseNumber`). Los integradores pueden resolver recursos por display ID sin conocer el ULID:

- `GET /api/v1/projects/{projectId}/suites/by-display-id/{displayId}` — `{projectId}` es el **código** del proyecto (por ejemplo `ACME`). `{displayId}` debe coincidir con `{projectId}-S{enteroPositivo}` (por ejemplo `ACME-S2`). Devuelve la misma forma `SuiteResponse` que `GET /api/v1/suites/{suiteUlid}`.
- `GET /api/v1/projects/{projectId}/test-cases/by-display-id/{displayId}` — `{displayId}` debe coincidir con `{projectId}-{enteroPositivo}` (por ejemplo `ACME-14`). Devuelve la misma forma `TestCaseResponse` que `GET /api/v1/test-cases/{caseUlid}`.

Display IDs mal formados devuelven `422 validation_failed`. IDs desconocidos devuelven `404 not_found`. Prefijos que no coinciden con el código del proyecto devuelven `400 validation_failed`.

Las ejecuciones y los hitos también tienen un número por proyecto (`runNumber` / `milestoneNumber`) y un `displayId` construido a partir de él. A diferencia de suites y casos, estos display IDs **no** llevan el código del proyecto como prefijo: el segmento `{projectId}` de la ruta acota la búsqueda:

| Recurso   | Display ID | Búsqueda                                                        | Misma forma que                          |
| --------- | ---------- | --------------------------------------------------------------- | ---------------------------------------- |
| Ejecución | `R-{n}`    | `GET /api/v1/projects/{projectId}/runs/by-display-id/R-12`      | `GET /api/v1/runs/{runUlid}`             |
| Hito      | `M-{n}`    | `GET /api/v1/projects/{projectId}/milestones/by-display-id/M-3` | `GET /api/v1/milestones/{milestoneUlid}` |

Los números empiezan en 1, aumentan con cada ejecución o hito creado en el proyecto y nunca se reutilizan: una ejecución o un hito eliminado deja un hueco. Un ID mal formado (`R-0`, `R-01`, `R-x`) o con el prefijo del otro recurso (`M-3` en la búsqueda de ejecuciones) devuelve `422 validation_failed`; un número desconocido, de otro proyecto o de un hito eliminado devuelve `404 not_found`. El ULID sigue siendo el identificador en el resto de endpoints de ejecuciones e hitos. La aplicación web usa los mismos IDs en sus URLs, por ejemplo `/projects/ACME/runs/R-12` y `/projects/ACME/milestones/M-3`.

**Mutaciones de suites.** Crear (`POST /api/v1/projects/{projectId}/suites`), renombrar o mover (`PATCH /api/v1/suites/{suiteUlid}`) y eliminar (`DELETE /api/v1/suites/{suiteUlid}`) suites requieren el permiso de ejecución de suites (que tienen `owner`/`admin`/`member`); un `viewer` recibe `403 forbidden`. Leer suites (árbol, detalle, búsqueda por display ID) no cambia para cualquier miembro autenticado.

## Casos de prueba archivados (Papelera)

`GET /api/v1/projects/{projectId}/test-cases/archived` devuelve un listado **por página**, a nivel de proyecto, de los casos de prueba archivados — el archivado más reciente primero (`archivedAt` descendente, ULID descendente como desempate). Acepta los parámetros estándar `page` / `pageSize` y responde con la forma de página estándar:

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

`total` cuenta **todos** los casos archivados del proyecto, no solo los de la página devuelta; una `page` posterior a la última sigue devolviendo `200` con `items: []` y el `total` real. El listado ignora el filtro de suite — siempre devuelve los casos archivados de todas las suites (y los casos sin suite), igual que la exclusión de casos archivados que ya aplica el listado por defecto `GET /api/v1/projects/{projectId}/test-cases`.

Este endpoint es **aditivo**: no reemplaza a `GET /api/v1/projects/{projectId}/test-cases?archived=true`, que mantiene su contrato y comportamiento por cursor (`{ items, nextCursor }`) sin cambios. Usa el endpoint por cursor para recorrer todo el conjunto archivado como feed; usa `/archived` para renderizar una vista de Papelera con páginas numeradas.

**Páginas relacionadas**

- [Restaurar casos archivados](https://docs.probara.net/es/guides/restoring-archived-cases/) — recorrido para usuarios de la vista de Papelera paginada y la selección entre páginas.

## Valores de custom fields en casos de prueba

Los casos de prueba ya no llevan los atributos de clasificación (`priority`, `severity`, `status`, `type`, `layer`, `behavior`, `isFlaky`, `preconditions`, `postconditions`) como claves del cuerpo. Esos atributos son ahora custom fields del sistema y se leen/escriben mediante el contrato dedicado de valores:

- `GET /api/v1/test-cases/{caseUlid}` incluye un array `customFieldValues` con una entrada por cada definición visible para el proyecto del caso. Si no hay valor guardado, la entrada toma el `defaultValue` de la definición (o `null`).
- `GET /api/v1/projects/{projectId}/test-cases` también incrusta `customFieldValues` en cada item con la misma forma. El endpoint de listado arma el array con una lectura masiva, así que la cantidad de round-trips a D1 es constante independientemente del tamaño de página.
- Cada entrada incluye dos claves opcionales y nulables además de `fieldUlid` y `value`:
  - `systemKey`: el `systemKey` de la definición (`'priority'`, `'severity'`, `'status'`, `'type'`, `'layer'`, `'behavior'`, `'is_flaky'`, `'preconditions'`, `'postconditions'`) cuando `group = 'system'`, o `null` cuando `group = 'custom'`.
  - `optionName`: el `name` de la opción seleccionada (`'High'`, `'Critical'`, `'Active'`, …) para tipos `select_single` / `radio` cuando `value` es un ULID válido de opción; `null` para otros tipos o cuando `value` es `null`. `optionName` se resuelve según el idioma de la solicitud, de la misma forma que se explica en [las definiciones de campos ahora son bilingües](#las-definiciones-de-campos-ahora-son-biling%C3%BCes) más abajo: `Accept-Language: es` devuelve el nombre en español cuando existe, y cae al inglés en caso contrario — un encabezado ausente o con un idioma no soportado resuelve a inglés, así que un cliente que nunca lo envía recibe respuestas idénticas byte a byte a las de antes de este cambio.

  Con esas claves, los clientes pueden renderizar chips de system fields sin pedir aparte la lista de definiciones. Los clientes antiguos que ignoran las claves nuevas siguen funcionando sin cambios.

**Formato al crear.** `POST /api/v1/projects/{projectId}/test-cases` acepta un `stepFormat` opcional (`classic` o `gherkin`). Si se omite, el caso toma el formato de pasos por defecto del proyecto, que se define con `defaultStepFormat` en `PATCH /api/v1/projects/{projectId}`.

**Valores al crear.** `POST /api/v1/projects/{projectId}/test-cases` acepta un array opcional `customFieldValues` con la misma forma `{ fieldUlid, value }` que `PUT .../custom-field-values`, de modo que un caso se crea con sus precondiciones, postcondiciones o cualquier otro campo en una sola llamada, con `currentRevision` en `1`:

```json
{
  "title": "Pagar con una tarjeta guardada",
  "customFieldValues": [{ "fieldUlid": "01J...PRECONDITIONS", "value": "Hay una tarjeta guardada" }]
}
```

Si se envía el array, debe incluir todo campo obligatorio que muestre el proyecto, salvo que el campo tenga un valor por defecto, que en ese caso se guarda. Un campo desconocido, un campo que el proyecto no muestra o un valor inválido rechaza la creación completa con `422 validation_failed` y no se crea ningún caso. Si se omite el array, el caso se crea con cada campo en su valor por defecto, como hasta ahora. Los valores quedan registrados en el evento `test_case.created` (`metadata.customFieldChanges`).

### Las definiciones de campos ahora son bilingües

Los títulos de campo, los placeholders y los nombres de opción ahora pueden llevar una traducción
al español. El título y el nombre de opción requieren un valor en inglés no vacío; el valor en
inglés de un placeholder es opcional (un campo puede no tener placeholder en ningún idioma), por lo
que `placeholderI18n.en` puede ser `null`. `GET /api/v1/orgs/{orgUlid}/custom-fields`
resuelve `title`, `placeholder` y el `name` de cada opción según el idioma de la solicitud
(`Accept-Language: es` devuelve el valor en español cuando existe, y cae al inglés en caso
contrario — un encabezado ausente o con un idioma no soportado resuelve a inglés, así que un
cliente que nunca lo envía recibe respuestas idénticas byte a byte a las de antes de este cambio).
Agrega `?locales=all` para recibir además `titleI18n`, `placeholderI18n` y el `nameI18n` de cada
opción — un mapa por etiqueta de idioma (`{ en, es }`) — junto a los valores ya resueltos. El
parámetro de búsqueda `q` encuentra un campo cuyo título está guardado en **cualquiera** de los
dos idiomas.

`POST /api/v1/orgs/{orgUlid}/custom-fields`, `PATCH .../custom-fields/{fieldUlid}` y
`POST .../custom-fields/{fieldUlid}/reset` aceptan el mismo parámetro `?locales=all` en la
respuesta `field` de un único objeto, con la misma condición: sin él, la respuesta se mantiene
idéntica byte a byte a la forma previa a la localización (sin ninguna clave `*I18n`), incluso
para integradores autenticados con API key.

La exportación (`GET /api/v1/projects/{projectId}/exports`) es la única excepción a la resolución
por idioma: los nombres de opción de un archivo exportado deliberadamente **no** se resuelven por
idioma y se mantienen siempre en inglés, para que un CSV/JSON exportado y su reimportación
coincidan siempre en un mismo vocabulario, sin importar quién descargó el archivo. Esto es
independiente del `customFieldValues.optionName` documentado arriba, que sí se resuelve por
idioma en cualquier lectura ordinaria.

### Actualización consolidada del caso (PATCH)

`PATCH /api/v1/test-cases/{caseUlid}` acepta un cuerpo consolidado que agrupa los cambios de campos escalares, la lista de pasos y los valores de campos personalizados en una sola petición. Es lo que usa el editor web al guardar; cada llamada genera **un único** evento de auditoría con un diff unificado.

```json
{
  "patch": {
    "title": "Login falla en móvil",
    "description": "...",
    "suiteUlid": "01J...SUITE",
    "milestoneUlid": null,
    "tags": ["smoke"]
  },
  "steps": [
    { "position": 1, "action": "Abrir la pantalla de login" },
    { "position": 2, "action": "Enviar credenciales inválidas" }
  ],
  "customFieldValues": [{ "fieldUlid": "01J...PRIORITY", "value": "01J...OPTION_HIGH" }]
}
```

`patch` también puede incluir `stepFormat` (`classic` o `gherkin`). Los pasos del caso después de la actualización, los `steps` enviados con ella o los actuales, deben encajar con el nuevo formato, o la actualización se rechaza con `422`. Un paso `gherkin` lleva un `keyword` (`Given`, `When`, `Then`, `And`, `But` o `*`) y no envía `expected`; un paso `classic` no envía `keyword`.

Debe estar presente al menos uno de `patch`, `steps` o `customFieldValues`. El handler lee el caso actual, los pasos y los valores de campos personalizados visibles antes de mutar, aplica las mutaciones solicitadas, computa el diff unificado y emite exactamente un evento `test_case.updated` (o `test_case.moved` si cambió `suiteUlid`). Si alguna sub-mutación falla, la respuesta es no-2xx y **no** se registra ningún evento de auditoría.

Requiere el permiso de escritura de casos de prueba (que tienen `owner`/`admin`/`member`); un token o sesión con rol `viewer` recibe `403 forbidden` y no se muta ningún campo.

En un caso sincronizado el escenario es de solo lectura. Un `PATCH` que cambie su título, su descripción, sus pasos, su formato de pasos, el valor de `preconditions` o la clave de automatización, o que quite una de sus etiquetas de archivo, responde `409 test_case_synced_read_only` y no cambia nada. Su suite, su responsable, su prioridad y los demás campos personalizados siguen siendo editables, y también las etiquetas que añades tú.

Los endpoints separados `PUT /api/v1/test-cases/{caseUlid}/steps` y `PUT /api/v1/test-cases/{caseUlid}/custom-field-values` siguen disponibles para integradores directos de la API. Cada uno registra un evento `test_case.updated` cuando cambia algo. `PUT .../steps` requiere el mismo permiso de escritura de casos de prueba (que tienen `owner`/`admin`/`member`); un `viewer` recibe `403 forbidden`.

### Ediciones simultáneas (`currentRevision`)

Toda respuesta de un caso de prueba incluye `stepFormat` (`classic` o `gherkin`) y `currentRevision`. La revisión empieza en `1` y sube en uno cada vez que se guarda el contenido del caso: el `PATCH` consolidado, `PUT .../steps`, `PUT` y `PATCH .../custom-field-values`, convertir pasos en un paso compartido o desvincular uno, y una importación que reemplaza el caso. También sube en uno cuando un paso compartido que usa el caso cambia lo que el caso muestra: cambiarle el título, reemplazar sus ítems o eliminarlo (un caso cuyo rango de ítems no incluye el cambio conserva su revisión). Archivar, restaurar y clonar no la modifican; un clon empieza en `1`.

Para no sobrescribir la edición de otra persona, envía como `expectedRevision` el `currentRevision` que leíste por última vez:

- En el cuerpo JSON del `PATCH` consolidado, de `PUT .../steps`, de `POST .../steps/convert-to-shared` y de los dos endpoints de valores de campos personalizados. En el `PATCH`, `expectedRevision` por sí solo no es un guardado: sigue siendo obligatorio al menos uno de `patch`, `steps` o `customFieldValues`.
- Como parámetro de consulta en la escritura sin cuerpo: `POST .../steps/{stepUlid}/detach?expectedRevision=4`.

Si el caso cambió entretanto, la escritura se rechaza con `409 conflict` y no se escribe nada:

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

Vuelve a leer el caso, aplica tu cambio sobre esa versión y reintenta. Sin `expectedRevision`, la escritura se aplica sobre lo que esté guardado y prevalece la última.

Convertir y desvincular también pueden responder `409 conflict` sin `details.reason` cuando los pasos o el paso compartido cambiaron mientras se procesaba la solicitud. No se escribe nada y esa misma solicitud puede reenviarse tal cual.

Las escrituras correctas devuelven la nueva revisión para que puedas encadenar guardados: el `PATCH` devuelve el caso, y `PUT .../steps`, convertir, desvincular y los dos endpoints de valores de campos personalizados añaden `currentRevision` a su cuerpo.

### Revisión obligatoria de casos de prueba (`202` y `review_required`)

Cuando un proyecto tiene la revisión de casos de prueba activada y **obligatoria** (plan Pro o Enterprise;
consulta [Configuración de revisión de casos](https://docs.probara.net/es/developers/api/project-review-settings/)), ninguna
escritura cambia directamente el contenido de un caso:

- `POST .../test-cases` y el `PATCH /test-cases/{caseUlid}` consolidado envían su cuerpo a revisión
  en su lugar. Responden `202 Accepted` con la
  [solicitud de revisión](https://docs.probara.net/es/developers/api/test-case-reviews/) que abrieron (`kind` `create` o
  `update`, los revisores por defecto del proyecto, tú como autor), y el repositorio no cambia hasta
  que se fusiona. Un `PATCH` de un caso que ya tiene una solicitud abierta responde `409 conflict`
  con `details.reason` `review_already_open`.
- `POST /test-cases/{caseUlid}/archive` y `DELETE /test-cases/{caseUlid}` responden `202` con una
  solicitud de revisión `archive` o `delete` en lugar de archivar o eliminar el caso, y
  `POST .../test-cases/bulk-archive` responde `202` con `{ created, skipped }`: una solicitud
  `archive` por caso, y los casos omitidos (con una solicitud abierta, ya archivados o fuera del
  proyecto), como `POST .../test-case-reviews/bulk`. `POST /test-cases/{caseUlid}/unarchive` nunca
  pasa por revisión.
- Cualquier otra escritura directa del contenido de un caso se rechaza con `409 conflict` y no se
  escribe nada: `PUT .../steps`, `PUT` y `PATCH .../custom-field-values`, convertir pasos en un paso
  compartido, desvincular uno, restaurar una revisión y clonar un
  caso o una suite en el proyecto.
- `DELETE /suites/{suiteUlid}` se rechaza con `409 conflict` (`review_required`) mientras la suite
  o alguna de sus descendientes tenga un caso sin archivar, sea cual sea el `caseDisposition`; no se
  elimina nada. Archiva primero esos casos mediante revisión. Una suite sin casos, o con solo casos
  archivados, se elimina como siempre.

```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" }
  }
}
```

Envía esos cambios como una solicitud de revisión, o en un único `PATCH` consolidado. Una
[importación CSV](https://docs.probara.net/es/developers/api/import/) abre una solicitud de revisión por caso. Mientras la
revisión esté desactivada u opcional, y en los planes sin revisión de casos, todas las escrituras
funcionan como antes.

### Estado de revisión (`reviewStatus`)

Cada respuesta de caso de prueba incluye `reviewStatus`: `not_reviewed`, `in_review` o `approved`.
Un caso empieza en `not_reviewed` (`approved` si se creó al fusionar una solicitud de revisión), está
en `in_review` mientras haya abierta una solicitud de actualización y pasa a `approved` cuando se
fusiona; rechazar una solicitud devuelve el estado que el caso tenía antes. Cualquier cambio directo
en el caso — el `PATCH` consolidado, `PUT .../steps`, los valores de campos personalizados,
restaurar una revisión o una importación que lo reemplaza — lo deja de nuevo en `not_reviewed`, y un
clon empieza en `not_reviewed`. Filtra por él con
`GET .../test-cases/search?reviewStatus=approved,in_review` o con el campo PQL `reviewStatus`.

### Historial de versiones (`/revisions`)

Cada guardado que sube `currentRevision` también guarda esa revisión del caso. Para leerlas:

- `GET /api/v1/test-cases/{caseUlid}/revisions`: las revisiones guardadas, de la más reciente a la más antigua, con paginación por cursor (`limit`, `cursor`). Cada elemento tiene `revision`, `source` (`edit`, `import`, `shared_step`, `restore`, `review` o `git_sync`, la [sincronización desde Git](#sincronización-de-archivos-gherkin) que reescribe un caso sincronizado), `author` (con la misma forma que `updatedBy`), `createdAt` e `isCurrent`; la respuesta también incluye el `currentRevision` del caso. Un caso que no se ha guardado desde que se creó devuelve la lista vacía: el propio caso es su única versión.
- `GET /api/v1/test-cases/{caseUlid}/revisions/{revision}`: una revisión con `snapshot`, el caso tal como lo dejó ese guardado: `title`, `description`, `suite`, `milestone`, `automationKey`, `tags`, `customFieldValues` y `steps` (los pasos compartidos, expandidos en los ítems que mostraban entonces). Una revisión de la que el caso no tiene copia guardada responde `404 not_found`.

Los campos con opciones (`select_single`, `radio`, `select_multi`) conservan el `valueText` guardado (ULID de las opciones) y añaden `selectedOptions`, con la etiqueta actual de cada opción en el idioma de la solicitud. Una opción eliminada después tiene `"label": null`:

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

El arreglo `images` de cada paso es siempre `[]`: las imágenes de un paso van insertadas en su texto, y una revisión las conserva a través de sus campos de texto enriquecido. Quien puede leer el caso puede leer su historial, también el de los casos archivados. En MCP: `list_test_case_revisions` y `get_test_case_revision`.

Una imagen sigue almacenada mientras cualquier texto o revisión guardada la referencie. `GET /api/v1/orgs/{orgUlid}/attachments` lista cada imagen de contenido una sola vez, como elemento `content_image`. `attachments:bulk-delete` no acepta imágenes de contenido y responde `not_found`: quita una imagen quitándola del texto que la contiene.

Leer las revisiones y restaurar una requieren un plan que incluya el historial de versiones (Pro y Enterprise). En los demás planes, los tres endpoints de `/revisions` y sus herramientas MCP responden `403 forbidden`; `GET /api/v1/orgs/current` incluye `caseHistoryEnabled` para que puedas comprobarlo antes. Todos los planes siguen guardando una revisión con cada guardado, y `currentRevision` y `expectedRevision` funcionan igual en todos los planes.

#### Restaurar una revisión

`POST /api/v1/test-cases/{caseUlid}/revisions/{revision}/restore` devuelve el caso a esa revisión. Es un guardado como cualquier otro: requiere el mismo rol que el `PATCH` (owner, admin o member), crea una revisión nueva con `source` `restore` y registra, con `restoredFrom`, el evento de actividad que registraría ese guardado: `test_case.updated`, o `test_case.moved` si la restauración cambia la suite. El cuerpo es opcional: un objeto JSON con un `expectedRevision` opcional, y un cuerpo vacío equivale a `{}`. Un `expectedRevision` desactualizado responde el conflicto `409 stale_revision` descrito arriba y no escribe nada.

Qué se recupera:

- Título, descripción, clave de automatización, etiquetas y pasos (con sus ULID e imágenes insertadas). Si otro caso tiene ahora la clave de automatización, la restauración responde `409 conflict` con `details.reason` `automation_key_taken` y no escribe nada.
- La suite y el hito, si todavía existen. Si la suite se eliminó, el caso se queda en su suite actual; si el hito se eliminó, el caso queda sin hito.
- Los campos personalizados, campo por campo. Un campo o una opción eliminados se omiten. Un campo que no tenía valor en esa revisión se vacía, salvo que sea obligatorio: entonces conserva su valor actual.
- Un paso compartido que todavía existe vuelve como referencia con su rango de ítems, así que muestra el contenido **actual** del paso compartido. Un paso compartido eliminado vuelve como pasos propios del caso, con el texto y las imágenes que mostraba esa revisión.

La respuesta es el caso, igual que la del `PATCH`, más `restore`:

```json
"restore": {
  "fromRevision": 4,
  "skipped": [
    { "kind": "milestone", "ulid": "01J...SPRINT", "name": "Sprint 12" },
    { "kind": "customFieldOption", "ulid": "01J...FRONTEND", "name": "Equipo" },
    { "kind": "sharedStepInlined", "ulid": "01J...LOGIN", "name": "Iniciar sesión" },
    { "kind": "image", "ulid": "01J...SHOT", "name": "Abrir el carrito" }
  ]
}
```

`skipped[].kind` es `suite`, `milestone`, `customField`, `customFieldOption` o `sharedStepInlined`; `name` es el nombre del elemento en esa revisión (para `customFieldOption`, el nombre del campo). Restaurar la revisión en la que ya está el caso responde `422 validation_failed` con `details.reason` `already_current`; una revisión sin copia guardada responde `404 not_found`. Si los pasos compartidos eliminados, al volver como pasos propios, dejaran el caso con más de 500 pasos, la restauración responde `422 validation_failed` con `details.reason` `case_step_limit`, `limit` y `cases` (el caso, con el `stepCount` que tendría), y no escribe nada. Un caso sincronizado no puede restaurarse a una revisión: la petición responde `409 test_case_synced_read_only` y no escribe nada. En MCP: `restore_test_case_revision`.

### Clave de automatización

Un caso de prueba puede llevar una `automationKey`: la clave estable que las herramientas de CI usan para vincular una prueba automatizada con el caso, por ejemplo `e2e/login.spec.ts > Login > rejects bad password`. Un caso con clave cuenta como automatizado.

- Se asigna al crear (`POST /api/v1/projects/{projectId}/test-cases`, `automationKey` opcional) o con `patch.automationKey` en el `PATCH` consolidado; `null` la borra. Toda respuesta de caso de prueba incluye `automationKey` (`null` cuando no está asignada).
- La clave se recorta y debe tener entre 1 y 1024 caracteres, sin caracteres de control; una clave en blanco se rechaza con `422 validation_failed`.
- Es única por proyecto, incluidos los casos archivados. Una clave que ya tiene otro caso se rechaza con `409 conflict` y `details.reason` `automation_key_taken`, y no se escribe nada. La misma clave puede usarse en proyectos distintos.
- Las clonaciones nunca copian la clave, y la aplicación web la muestra en solo lectura en el detalle del caso.
- La clave no lleva parámetros. Un caso parametrizado tiene una sola clave, y sus casos de ejecución se distinguen por sus valores; consulta [Casos de prueba parametrizados](https://docs.probara.net/es/guides/parameterized-test-cases/).
- En un caso sincronizado la clave es de solo lectura: es la clave de su escenario, y un guardado que la cambie responde `409 test_case_synced_read_only`.

### Casos sincronizados

Cada respuesta de caso de prueba incluye `source`: `manual` para un caso escrito en Probara, o `git` para un caso que la [sincronización Gherkin](#sincronización-de-archivos-gherkin) sincronizó desde un archivo `.feature`. Un caso `git` también incluye `sourceFile`: la `path` del archivo relativa al repositorio, la `line` de su escenario, su `url` `https` cuando la sincronización la conoce (si no, `null`) y las `tags` que le da el archivo. Un caso `manual` tiene `sourceFile: null`.

Un caso `git` es de solo lectura para su título, su descripción, sus pasos y su formato de pasos, el valor de `preconditions`, su clave de automatización y las etiquetas que le da su archivo. Un guardado que cambie una de ellas, o que quite una de esas etiquetas, responde `409 test_case_synced_read_only` y no escribe nada. Ese mismo código rechaza una restauración de revisión, una solicitud de revisión (individual o masiva), y la conversión de sus pasos en un paso compartido. Su responsable, su prioridad, los campos personalizados distintos de `preconditions`, los defectos, los enlaces, los comentarios, la pertenencia a planes y ejecuciones, su suite y las etiquetas que añades tú siguen siendo editables.

### Creado y actualizado por

Toda respuesta de caso de prueba incluye `author` — quién creó el caso — y `updatedBy` — quién hizo el cambio que fijó `updatedAt` por última vez. Cada uno es un miembro, `{ "kind": "user", "ulid", "displayName", "avatarKey" }`, o un token API, `{ "kind": "api_token", "ulid", "name", "app" }`, con la misma forma que el `author` de una ejecución: `app` es `"junit"`, `"playwright"`, `"jest"`, `"cypress"`, `"vitest"` o `"cucumber"` para un token de aplicación de la organización y `null` para un token API personal, que aparece con su propio nombre y no con el de su dueño.

- Crear un caso — con esta API, un reporte de CI, una importación, una clonación o una sincronización desde Git — fija ambos a quien hace la llamada. Una clonación la firma quien clona, no el autor del caso de origen.
- Todo cambio que actualiza `updatedAt` fija `updatedBy` y no toca `author`: el `PATCH` consolidado, archivar, restaurar, archivar en lote, un reporte de CI que vincula un caso existente con su prueba automatizada, una importación que reemplaza un caso, una sincronización desde Git y eliminar una suite que mueve o archiva sus casos.
- Ambos son `null` para un caso creado antes de que Probara registrara la autoría, y para un miembro o token que se ha eliminado de forma definitiva.

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

### Texto e imágenes de los pasos

Cada paso clásico tiene tres campos de texto enriquecido, `action`, `data` y `expected`, con los documentos `actionDocument`, `dataDocument` y `expectedDocument`. Las imágenes van insertadas en estos campos, como se describe en [Campos de texto enriquecido](#campos-de-texto-enriquecido). Los pasos Gherkin conservan texto plano y no tienen documentos. Los pasos no tienen galería de imágenes: su arreglo `attachments` siempre es `[]`, y nombrar uno se rechaza.

En un caso sincronizado, el texto de los pasos es de solo lectura: un guardado que lo cambie responde `409 test_case_synced_read_only`.

- `PUT /api/v1/test-cases/{caseUlid}/custom-field-values` reemplaza atómicamente el conjunto de valores del caso:

```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": "Sesión iniciada" }
    ]
  }' \
  "https://app.probara.net/api/v1/test-cases/01J.../custom-field-values"
```

En campos `checkbox`, el booleano JSON (`true` / `false`) coincide con lo que devuelve el `GET`; el formato legado con cadenas (`"true"` / `"false"`) sigue aceptándose.

Los valores vacíos / `null` borran la fila correspondiente. La respuesta es `200` con el array embebido completo. En un caso sincronizado el valor de `preconditions` es de solo lectura: un guardado que lo cambie responde `409 test_case_synced_read_only`. Las reglas de validación por tipo de campo viven en el esquema OpenAPI (`PutCustomFieldValuesInput`).

Para descubrir los ULIDs de los campos del sistema (`priority`, `severity`, etc.), llama a `GET /api/v1/orgs/{orgUlid}/custom-fields`. Añade `?projectUlid={projectUlid}` para filtrar a las definiciones visibles para un proyecto concreto (útil al construir formularios de caso).

### Actualizar un solo valor de custom field

`PATCH /api/v1/test-cases/{caseUlid}/custom-field-values/{fieldUlid}` inserta o actualiza **un** campo sin reemplazar el resto de valores del caso. El cuerpo es `{ "value": <unknown> }`, más un [`expectedRevision`](#ediciones-simultáneas-currentrevision) opcional; ninguna otra clave. La respuesta es `200` con el mismo array embebido completo `customFieldValues` que devuelve el `PUT`.

El `PATCH` solo exige obligatorio en el campo **objetivo**; no revisa si otros campos obligatorios están vacíos. Usa `PUT` para un reemplazo atómico de formulario completo, cuando todos los campos obligatorios visibles deben ir en el cuerpo.

```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"
```

Los valores vacíos / `null` borran la fila si el campo no es obligatorio. Intentar vaciar un campo obligatorio devuelve `422 validation_failed` con `details` en `path: ["value"]`.

### Actualizar un custom field

`PATCH /api/v1/orgs/{orgUlid}/custom-fields/{fieldUlid}` actualiza metadatos de la definición y, en tipos basados en opciones, la lista `options`. Cada entrada en `options` PUEDE incluir el `ulid` de la opción existente. Si el `ulid` coincide con una fila ya guardada para el campo, el PATCH conserva ese ULID y actualiza `name`, `icon`, `color` y `position` en el mismo registro. Las entradas sin `ulid` se insertan como opciones nuevas con un ULID recién generado. Las filas cuyo `ulid` no aparece en el patch se eliminan (en campos de sistema sigue prohibido quitar opciones semilla con `systemKey`).

Como los valores de `select_single`, `select_multi` y `radio` guardan ULIDs de opción en `custom_field_values`, devuelve siempre el `ulid` que entregó el `GET` cuando quieras modificar una opción existente. Si omites `ulid` en una fila que ya existía, se crea una opción nueva y los valores guardados que apuntaban al ULID anterior quedan huérfanos.

### Los campos obligatorios de ejecución requieren un valor por defecto

`entity` en `POST /api/v1/orgs/{orgUlid}/custom-fields` y `PATCH /api/v1/orgs/{orgUlid}/custom-fields/{fieldUlid}` acepta `test_case`, `test_run` y `defect`. Una definición con `entity: "test_run"` e `isRequired: true` DEBE tener un `defaultValue` no vacío. Incumplir esto en cualquiera de los dos endpoints devuelve `422 validation_failed` con `details.field = "defaultValue"`, y no crea ni modifica ninguna fila de definición. El invariante se evalúa sobre el estado efectivo de la definición después de la escritura, así que marcar como obligatorio un campo `test_run` existente sin valor por defecto guardado se rechaza en `PATCH` igual que en `POST`; enviar `isRequired` y un `defaultValue` no vacío juntos en la misma solicitud se acepta. Los campos `test_case` y `defect` quedan exentos y pueden seguir siendo obligatorios sin valor por defecto, como antes.

### Restablecer un campo de sistema a los valores semilla

Los propietarios y administradores pueden devolver una definición **de sistema** a su estado sembrado:

```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** — Devuelve `{ field }` con título semilla, placeholder vacío, nombres/iconos/colores de opciones semilla y ULIDs estables (por `systemKey`). Las opciones agregadas por el usuario (`systemKey: null`) se eliminan.
- **400 `not_system_field`** — El objetivo no es un campo de sistema.
- **403** — El usuario no es propietario ni administrador.
- **404** — Campo u organización desconocidos, o acceso entre inquilinos.

La operación es **idempotente** si el campo ya estaba en estado semilla. El **alcance por proyecto** (`allProjects`, `projectUlids`) no cambia.

**Cascada de valores:** Al eliminar opciones de usuario, los valores guardados en casos que apuntaban a esos ULIDs se actualizan en la misma petición: en `select_single` / `radio` se usa el ULID del valor por defecto semilla si existe; si no, se borra la fila. En `select_multi` se filtran los ULIDs eliminados y, si el array queda vacío, se aplica el valor por defecto semilla. Los valores de tipos no basados en opciones (párrafo, checkbox, etc.) no se modifican.

### Eliminar un custom field

Los propietarios y administradores pueden eliminar un campo **custom**. Antes, consulta qué se perdería:

```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"
```

Devuelve `{ valueCount, projectCount }`: los valores guardados para el campo en casos de prueba, ejecuciones y defectos, y en cuántos proyectos están.

`DELETE /api/v1/orgs/{orgUlid}/custom-fields/{fieldUlid}` elimina la definición. Si el campo todavía tiene valores, la petición se rechaza salvo que agregues `?deleteValues=true`, que elimina de forma permanente todos sus valores junto con el campo.

- **204** — Se eliminó el campo (y, con `deleteValues=true`, sus valores).
- **409 `field_has_values`** — El campo tiene valores y no se envió `deleteValues=true`.
- **403 `system_field`** — Los campos de sistema no se pueden eliminar, con o sin `deleteValues`.
- **404** — Campo u organización desconocidos, o acceso entre inquilinos.

## Casos de prueba parametrizados

Un caso de prueba tiene `parameters` (entradas simples, de grupo y compartidas, en orden) y `excludedCombinations`. Las respuestas del caso incluyen también `combinationCount`, las combinaciones que producen los parámetros antes de las exclusiones: 1 para un caso sin parámetros. Una ejecución crea un caso de ejecución por cada combinación que no esté excluida. Las reglas y los límites están en [Casos de prueba parametrizados](https://docs.probara.net/es/guides/parameterized-test-cases/); un valor que supera un límite se rechaza con `422 validation_failed`.

| Campo                  | Dónde                                                  | Significado                                                                                                                                                                                                                                       |
| ---------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `parameters`           | Creación y actualización del caso; respuestas del caso | Los parámetros en orden. Una entrada `single` tiene `name` y `values`; un `group` tiene `name`, `columns` y `rows`; una entrada `shared` tiene `sharedParameterUlid`. Las respuestas resuelven una entrada compartida a los valores que contiene. |
| `excludedCombinations` | Creación y actualización del caso; respuestas del caso | Cada combinación excluida es una lista de pares `{ name, value }`, con un valor para cada parámetro del caso. Una escritura reemplaza el conjunto completo; si omites el campo, se conserva.                                                      |
| `combinationCount`     | Respuestas del caso                                    | Las combinaciones antes de las exclusiones.                                                                                                                                                                                                       |

Los parámetros compartidos viven en `/api/v1/projects/{projectId}/shared-parameters`:

| Método y ruta                                                       | Finalidad                                                                                                                                                                                                                                                                          |
| ------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` `/shared-parameters`                                          | Lista los parámetros compartidos que el proyecto puede usar: los propios y los de la organización disponibles para él.                                                                                                                                                             |
| `POST` `/shared-parameters`                                         | Crea uno. `scope` es `project` u `organization`. Un parámetro de organización puede limitarse con `projectUlids` y necesita el derecho de pasos compartidos de la organización.                                                                                                    |
| `GET`, `PATCH`, `DELETE` `/shared-parameters/{sharedParameterUlid}` | Lee, cambia o elimina uno. El tipo y el alcance se fijan al crearlo. Un cambio llega a todos los casos que usan el parámetro. `DELETE` acepta `mode=convert_to_local` (el valor por defecto: cada caso conserva su copia) o `mode=remove` (la referencia sale de todos los casos). |
| `GET` `/shared-parameters/{sharedParameterUlid}/usage`              | Lista los proyectos cuyos casos usan el parámetro, con el número de casos en cada uno.                                                                                                                                                                                             |

Dos endpoints actúan sobre un caso de prueba:

| Método y ruta                                                                            | Finalidad                                                                                                                                                                                |
| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET` `/api/v1/projects/{projectId}/test-cases/{caseUlid}/combinations`                  | La vista previa de combinaciones: `combinationCount`, las `combinations` que quedan tras las exclusiones y las `excludedCombinations`.                                                   |
| `POST` `/api/v1/projects/{projectId}/test-cases/{caseUlid}/parameters/{position}/detach` | Convierte la entrada compartida en `position` en una copia local de sus valores. Devuelve los parámetros del caso, las exclusiones, el número de combinaciones y la revisión que guardó. |

Las respuestas de ejecución llevan las mismas combinaciones:

- Un caso de ejecución tiene `parameters`, la combinación que representa (`[]` para un caso sin parámetros). Su detalle tiene `siblings`, los demás casos de ejecución del mismo caso en la ejecución, y `reportedParameters`, los parámetros que reportó su intento actual. `reportedParameters` era el campo `parameters` del detalle antes de este cambio.
- `POST /api/v1/runs/{runUlid}/cases/{runCaseUlid}/apply-latest` se rechaza con `409 conflict` y `details.reason: combination_removed` cuando la última versión ya no produce la combinación del caso de ejecución. La aplicación en lote muestra el mismo motivo en `skipped`.
- Un resultado tiene `runCaseUlid`, el caso de ejecución al que pertenece (`null` cuando ese caso de ejecución se eliminó), y `parameters`, su combinación.
- `GET /api/v1/runs/{runUlid}/case-keys` lista para cada caso de ejecución su `caseDisplayId`, su `automationKey` y sus `parameters`, una entrada por instancia.
- La vista pública de una ejecución compartida devuelve la misma forma de caso de ejecución que la API autenticada, así que sus casos de ejecución llevan también `parameters`.

## Adjuntos de casos de prueba

La sección de adjuntos de un caso de prueba lista dos tipos de entradas: las imágenes que sus textos referencian y los archivos sueltos subidos al caso. Ambos vienen de `GET /api/v1/test-cases/{caseUlid}/attachments`.

Los archivos sueltos siguen las reglas de [adjuntos de evidencia de los defectos](https://docs.probara.net/es/developers/api/defects/): se acepta cualquier tipo salvo ejecutables y scripts, los archivos vacíos se rechazan, las imágenes PNG, JPEG y WebP se convierten a WebP con miniatura, y cualquier otro archivo se guarda tal cual. Un caso admite como máximo **20** archivos sueltos, y una petición lleva hasta **20** partes `file`. Las subidas suman al contador de almacenamiento de la organización `case_attachment`.

Los visualizadores pueden listar adjuntos; solo **miembros y roles superiores** pueden subir o eliminar.

### Listar

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

Devuelve `{ attachments: [...] }`. Primero van las imágenes, en el orden del texto: la descripción, después los campos personalizados de párrafo y luego los pasos por posición. Una imagen tiene `kind: "image"` y sus `ulid`, `mime`, dimensiones, `byteSize`, `url`, `thumbUrl` y `createdAt`, más un campo `field` que indica dónde está:

- `{ "kind": "description" }` para la descripción.
- `{ "kind": "custom_field", "fieldUlid": "…", "name": "…" }` para un campo personalizado de párrafo.
- `{ "kind": "step", "stepUlid": "…", "position": 2, "part": "expected" }` para el texto de acción, datos o resultado esperado de un paso. `position` es la posición del paso, empezando en 1.

Después van los archivos sueltos, del más antiguo al más reciente. Cada uno tiene `kind: "file"` y los campos de un adjunto de defecto: `ulid`, `mime`, dimensiones, `byteSize`, `originalFilename`, `uploadedBy`, `url`, `thumbUrl` y `createdAt`.

### Subir

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

`multipart/form-data` con una o más partes `file`. Devuelve `201` con `{ attachments: [...] }`, un archivo suelto por parte. Un tipo bloqueado, un archivo vacío, un archivo demasiado grande o una petición que llevaría el caso por encima de 20 archivos sueltos devuelve `422 validation_failed` y no guarda nada. Una subida que superaría la cuota de almacenamiento de la organización devuelve `413 storage_quota_exceeded`. Emite `test_case.attachment_added`.

Es una subida directa, no una preparación, e ignora la cabecera [`Idempotency-Key`](https://docs.probara.net/es/developers/api/idempotency/). Un reintento puede guardar los mismos archivos dos veces: tras un timeout u otro fallo ambiguo, lista los adjuntos del caso antes de reintentar.

### Eliminar

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

Devuelve `204`. Elimina el archivo suelto, su objeto guardado y su miniatura, y reembolsa sus bytes de almacenamiento. Emite `test_case.attachment_deleted`. El ULID de una imagen devuelve `404 not_found`: una imagen sale del caso cuando la quitas de su texto.

### Archivos sueltos y el ciclo de vida del caso

- Las revisiones no guardan archivos sueltos, así que al restaurar una revisión estos se mantienen tal como están.
- Clonar un caso no copia sus archivos sueltos. La copia empieza sin ninguno.
- Archivar un caso conserva sus archivos sueltos. Al eliminar una suite con `caseDisposition: "deleteCases"`, sus casos se archivan en lugar de borrarse, así que sus archivos se quedan con ellos.
- Eliminar un caso o un proyecto quita sus archivos sueltos, con sus bytes y sus objetos guardados.

## Proyectos

`PATCH /api/v1/projects/{projectId}` requiere el permiso de gestión de proyectos (que tienen **`owner`**/**`admin`**). **`member`** y **`viewer`** reciben **`403 forbidden`** con `error.code` `forbidden`. Crear un proyecto (`POST /api/v1/projects`) requiere el mismo permiso de gestión de proyectos: **solo `owner`/`admin` pueden crear un proyecto** — un `member` o `viewer` ahora también recibe `403 forbidden`. `GET` en `/api/v1/projects` y `GET /api/v1/projects/{projectId}` no cambian para los miembros autorizados de la organización.

`GET /api/v1/projects/{projectId}` incluye `avatarKey` (`string | null`) y `avatarVersion` (`number`). Cuando `avatarKey` no es nulo, la imagen se sirve públicamente en `GET /__assets/{avatarKey}?v={avatarVersion}` (sin sesión).

| Método   | Ruta                                  | Cuerpo                                                          | Respuesta                            |
| -------- | ------------------------------------- | --------------------------------------------------------------- | ------------------------------------ |
| `POST`   | `/api/v1/projects/{projectId}/avatar` | `multipart/form-data` con un `file` (PNG/JPEG/WebP, máx. 5 MiB) | `{ avatarKey, avatarVersion }`       |
| `DELETE` | `/api/v1/projects/{projectId}/avatar` | —                                                               | `{ avatarKey: null, avatarVersion }` |

`POST`/`DELETE` en `/api/v1/projects/{projectId}/avatar` requieren **`owner`** o **`admin`**. Las imágenes se normalizan en el servidor a WebP 256×256 bajo `avatars/projects/{projectUlid}.webp`.

### Control de acceso

`GET`/`PATCH /api/v1/projects/{projectId}/access` leen y cambian el modo de acceso público/privado de un
proyecto y su propietario — un recurso **dedicado**; el estado de acceso nunca se agrega a la respuesta
general de `GET /projects/{projectId}`. `PATCH` requiere ser **`owner`**/**`admin`** de la organización o el
**propietario actual** del proyecto — un miembro que solo tiene acceso de lectura (una fila `granted` en la
lista de acceso) no está automáticamente autorizado a alternarlo.

`GET`/`POST`/`DELETE /api/v1/projects/{projectId}/access/members` listan, otorgan y revocan miembros
individuales en la lista de acceso de un proyecto privado, sin cambiar nunca el modo del proyecto — solo
`PATCH /access { mode }` hace eso. Misma regla de autorización que el alternador. Eliminar el **último**
otorgamiento restante ahora tiene éxito (`204`): la lista de acceso puede legítimamente quedar vacía en un
proyecto privado; publicar un proyecto siempre es un cambio de modo explícito a través de
`PATCH /access { mode }`, nunca un efecto secundario de revocar al último miembro de la lista.

Ver [Acceso a proyectos](https://docs.probara.net/es/developers/api/project-access/) para la forma completa de las peticiones y
respuestas, las reglas de siembra al alternar, y una limitación documentada: los tokens de API nunca se
evalúan contra la lista de acceso. La aplicación en sí sigue a los datos, no a la forma de la URL — las
rutas que resuelven su proyecto de forma transitiva a partir de un ULID de entidad quedan protegidas igual
que `/projects/{projectId}/*`.

Un [grupo de usuarios](https://docs.probara.net/es/developers/api/user-groups/) también puede asignarse a la lista de acceso de
un proyecto, otorgando acceso a todos los miembros actuales y futuros del grupo en una sola operación:

| Método   | Ruta                                                         | Notas                                                                                                                                                        |
| -------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET`    | `/api/v1/projects/{projectId}/access/groups`                 | Lista los grupos asignados a la lista de acceso del proyecto                                                                                                 |
| `POST`   | `/api/v1/projects/{projectId}/access/groups`                 | Asigna un grupo (`{ userGroupUlid }`); tiene éxito en un proyecto público — la asignación queda dormida hasta que sea privado                                |
| `DELETE` | `/api/v1/projects/{projectId}/access/groups/{userGroupUlid}` | Desasigna un grupo; nunca `409`, ni siquiera para el último grupo restante                                                                                   |
| `GET`    | `/api/v1/groups/{userGroupUlid}/projects`                    | La vista inversa: los proyectos a los que está asignado un grupo dado (ver abajo)                                                                            |
| `POST`   | `/api/v1/groups/{userGroupUlid}/projects`                    | Asigna el grupo a uno o varios proyectos en una sola solicitud (`{ projectUlids: string[] }`); sin barrera de organización propia — se autoriza por proyecto |

Misma regla de autorización que la lista de acceso individual (**`owner`**/**`admin`** de la
organización, o el propietario actual del proyecto). A diferencia de la familia individual, asignar
un grupo está permitido en un proyecto **público**: `access_mode` es un dato almacenado, nunca
inferido de una asignación, así que el otorgamiento queda dormido y sobrevive a cualquier cambio de
modo posterior en cualquier dirección. No existe una revocación a nivel de grupo — la fila `revoked`
propia de la lista de acceso individual siempre prevalece sobre un otorgamiento por grupo.

`GET /api/v1/groups/{userGroupUlid}/projects` está abierto a lectura como los demás `GET` de grupos
(ver [Grupos de usuarios](https://docs.probara.net/es/developers/api/user-groups/)), así que cualquier miembro de la
organización puede llamarlo — pero, a diferencia del `projectCount` propio del grupo, este listado
filtra cualquier proyecto que el llamante no pueda descubrir de otra forma: un proyecto privado al
que el grupo está asignado se omite silenciosamente de `items` y `total` para ese llamante, mientras
que un **`owner`**/**`admin`** de la organización ve todas las asignaciones.

### Configuración de ejecución

`GET`/`PATCH /api/v1/projects/{projectId}/run-settings` leen y cambian las doce
configuraciones que gobiernan el marcado, la escritura en ejecuciones cerradas y los valores
predeterminados de creación de runs — resueltas mediante
`proyecto → organización → valor por defecto del código` para once de las doce claves.
`GET`/`PATCH /api/v1/orgs/{orgUlid}/run-settings` leen y cambian los valores predeterminados a
nivel de organización para esas mismas once claves. `PATCH` en el endpoint de proyecto requiere
`owner`/`admin` de la organización o el propietario actual del proyecto; `PATCH` en el endpoint
de organización requiere el permiso `org-settings.manage`. Consulta
[Configuración de ejecución](https://docs.probara.net/es/developers/api/project-run-settings/) para el vocabulario
completo, el contrato de resolución/`source`, y la única brecha documentada aplicada solo en el
cliente (`requireCommentOnNegativeResult`).

### Configuración de revisión de casos

`GET`/`PATCH /api/v1/projects/{projectId}/review-settings` leen y cambian si los cambios en los
casos de prueba del proyecto pasan por revisión, y cómo: `mandatory`, `allowSelfMerge`,
`approvalsRequired`, `autoMerge`, `onlyApprovedInRuns` y `defaultReviewerUserIds`. Siguen las mismas reglas de lectura y
escritura que la configuración de ejecución. Solo en los planes Pro y Enterprise: en los demás planes ambos
responden `403 forbidden`. Consulta
[Configuración de revisión de casos](https://docs.probara.net/es/developers/api/project-review-settings/).

### Solicitudes de revisión de casos

`POST /api/v1/projects/{projectId}/test-case-reviews` envía un caso nuevo o un cambio en un caso
existente a revisión en lugar de guardarlo; `GET .../test-case-reviews/{reviewUlid}` lee una
solicitud. `GET .../test-case-reviews` las lista, `GET .../{reviewUlid}/versions` responde la versión
propuesta junto al caso tal como está ahora, y `approve`, `unapprove`, `request-changes`, `PATCH` (editar),
`reviewers`, `decline` y `merge` actúan sobre una solicitud abierta; `.../{reviewUlid}/comments`
guarda su conversación. `POST .../test-case-reviews/bulk` envía casos a revisión tal como están, y
`POST .../test-case-reviews/bulk-decision` decide sobre varias solicitudes a la vez. Consulta
[Solicitudes de revisión de casos](https://docs.probara.net/es/developers/api/test-case-reviews/). Un caso sincronizado no admite solicitudes de revisión: crear una responde `409 test_case_synced_read_only`, y una solicitud masiva que nombre uno se rechaza completa.

### Listado: estadísticas opcionales y filtro por miembro

`GET /api/v1/projects` acepta dos parámetros de consulta opcionales y aditivos.
Ambos son opt-in: una solicitud **sin** ellos devuelve exactamente la forma plana
actual de cada item, y las respuestas de `POST`/`GET /api/v1/projects/{projectId}`
**no cambian**.

| Parámetro       | Notas                                                                                                                                                                                                                                                                                                                            |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `include=stats` | Incrusta un objeto `stats` opcional, un arreglo `team` y un entero `teamCount` en **cada item del listado**. El único valor aceptado es `stats`.                                                                                                                                                                                 |
| `memberUlid`    | Conjunto de ULIDs de miembros separados por comas. Filtra la página (y `total`) a los proyectos cuyo equipo incluye a alguno de los miembros indicados. Los ULIDs desconocidos (que no sean miembros activos de tu organización) se descartan en silencio. Es independiente de `include`: puedes filtrar sin pedir estadísticas. |

Cuando `include=stats` está presente, cada item lleva además:

| Campo                     | Tipo    | Significado                                                                                                                                                                                                                                         |
| ------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `stats.cases`             | entero  | Casos de prueba no archivados                                                                                                                                                                                                                       |
| `stats.runs`              | entero  | Todas las ejecuciones                                                                                                                                                                                                                               |
| `stats.runsInProgress`    | entero  | Ejecuciones con `state = open`                                                                                                                                                                                                                      |
| `stats.defectsUnresolved` | entero  | Defectos con estado `open` o `in_progress`                                                                                                                                                                                                          |
| `stats.milestones`        | entero  | Hitos vigentes (no eliminados)                                                                                                                                                                                                                      |
| `team`                    | arreglo | Subconjunto liviano y listo para avatares del equipo del proyecto — como máximo 6 miembros, ordenados por fecha de incorporación a la membresía en orden ascendente. Cada entrada es `{ userUlid, firstName, lastName, avatarKey, avatarVersion }`. |
| `teamCount`               | entero  | Tamaño total del equipo del proyecto (alimenta un indicador de excedente `+N`).                                                                                                                                                                     |

Un proyecto sin filas que coincidan reporta `0` en cada métrica (nunca un campo
ausente). Todos los conteos están aislados por organización: solo incluyen las
filas de tu propia organización.

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

> **`team`/`teamCount`/`memberUlid` dependen del modo.** En un proyecto **público** el equipo es la
> membresía activa de la organización (menos quien haya sido revocado individualmente) — el
> comportamiento opt-out original, sin cambios. En un proyecto **privado** el equipo es exactamente los
> miembros con una fila `granted` en la lista de acceso, intersectados con la membresía activa; los
> miembros que alcanzan el proyecto solo mediante el bypass de rol `owner`/`admin` de la organización no
> tienen otorgamiento y están correctamente **ausentes** del equipo, aunque sí aparecen en
> [la lista de miembros con acceso del proyecto](https://docs.probara.net/es/developers/api/project-access/). `memberUlid` filtra
> sobre este mismo conjunto dependiente del modo, así que tiene efecto real tanto en proyectos públicos
> como privados.

### Listado: filtros de presencia y estado

`GET /api/v1/projects` también acepta cuatro filtros opcionales y aditivos de
**presencia/estado** que acotan qué proyectos aparecen. Se aplican **del lado del
servidor**, por lo que el `total` devuelto y la paginación siempre reflejan el
conjunto filtrado. Cada filtro toma una **lista de tokens de opción separados por
comas** (también puedes repetir el parámetro, por ejemplo
`runs=without&runs=active`). Dentro de un mismo filtro las opciones seleccionadas
se combinan con **OR**; entre filtros distintos se combinan con **AND**. Se
encadenan con `q` y `memberUlid`, y una solicitud sin ninguno de ellos no cambia.
**Los tokens desconocidos se descartan en silencio** (sin `422`); un filtro cuyos
tokens son todos desconocidos se trata como ausente.

Cada filtro refleja exactamente la definición de la métrica correspondiente, así
que filtrar concuerda con los conteos que ves bajo `include=stats`.

| Parámetro    | Opciones  | Significado                                                                        |
| ------------ | --------- | ---------------------------------------------------------------------------------- |
| `runs`       | `without` | Proyectos **sin** ejecuciones                                                      |
|              | `active`  | Proyectos con al menos una ejecución **abierta** (`state = open`)                  |
|              | `any`     | Proyectos con al menos una ejecución (cualquier estado)                            |
| `defects`    | `has`     | Proyectos con al menos un defecto **sin resolver** (estado `open` o `in_progress`) |
|              | `without` | Proyectos **sin** defectos sin resolver (solo resueltos o ninguno)                 |
| `milestones` | `has`     | Proyectos con al menos un hito **vigente** (no eliminado)                          |
|              | `without` | Proyectos sin hitos vigentes                                                       |
| `cases`      | `has`     | Proyectos con al menos un caso de prueba **activo** (no archivado)                 |
|              | `without` | Proyectos sin casos activos                                                        |

> **`defects` significa sin resolver.** El filtro `defects` solo considera
> defectos cuyo estado es `open` o `in_progress` — la misma definición que
> `stats.defectsUnresolved`. Los defectos resueltos o cerrados nunca hacen que un
> proyecto coincida con `defects=has`.

Los cuatro filtros están aislados por organización: solo consideran filas de tu
propia organización.

```bash
# Proyectos con una ejecución abierta Y sin defectos sin resolver, con nombre tipo "mobile":
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/projects?q=mobile&runs=active&defects=without"

# OR dentro de un filtro: proyectos sin ejecuciones O con una ejecución abierta:
curl -sS \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  "https://app.probara.net/api/v1/projects?runs=without,active"
```

## Ejecuciones y resultados

Una ejecución congela los casos seleccionados en ella. Crea la ejecución con los
casos a ejecutar y luego lee y muta sus casos:

| Método          | Ruta                                                          | Notas                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| --------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST`          | `/api/v1/projects/{projectUlid}/runs`                         | Body `{ name, description?, defaultAssigneeUlid?, environmentId?, environment?, caseUlids?[], planUlid?, plan?, milestoneId?, milestone?, configurationUlids?[], configurations?[], customFieldValues?[], source?, automated? }` (`source`: ver [Origen de la ejecución](#origen-de-la-ejecución); `environment`, `milestone`, `plan` y `configurations`: ver [Referencias por nombre en ejecuciones](#referencias-por-nombre-en-ejecuciones)). Congela título + pasos de cada caso. `caseUlids` (1–5000 si se envía) es obligatorio salvo que se envíe `planUlid` o `plan` (los casos y los responsables por caso se siembran desde la selección actual del plan) o `automated: true` (permite una ejecución sin casos, para herramientas de CI que reportan en ella después; los casos de `caseUlids` o `planUlid` se siguen añadiendo; solo de la solicitud, no se guarda). Una sola solicitud crea exactamente una ejecución — `planUlid` nunca genera más de una.                                                                                                                                |
| `GET`           | `/api/v1/projects/{projectUlid}/runs`                         | Cada elemento incluye progreso (`total`, `executed`, `passRate`), un objeto `counts` exacto de 5 bandas, agregados temporales (`firstResultAt`, `lastResultAt`, `totalDurationMs`) y un `author` discriminado (ver abajo).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            |
| `GET`           | `/api/v1/projects/{projectUlid}/runs/environments`            | **Deprecado.** Devuelve `{ items: string[] }` con los valores de texto libre `environment` no vacíos y distintos del proyecto, ordenados ascendentemente. Se mantiene por compatibilidad; se recomienda filtrar por ULID con `environmentId` en su lugar.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `GET`           | `/api/v1/projects/{projectId}/runs/by-display-id/{displayId}` | Resuelve una ejecución por su ID legible `R-{n}`; `{projectId}` es el código del proyecto. Mismo cuerpo que `GET /api/v1/runs/{runUlid}`. Ver [Búsqueda por display ID](#búsqueda-por-display-id).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `GET` / `PATCH` | `/api/v1/runs/{runUlid}`                                      | `PATCH` actualiza solo metadatos (`name`, `description`, `defaultAssigneeUlid`, `environmentId`, `milestoneId`, `configurationUlids`); el `status` agregado se deriva, nunca se parchea. Permitido en una ejecución cerrada y no abortada; devuelve `409 conflict` solo cuando la ejecución está **abortada**.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        |
| `POST`          | `/api/v1/runs/{runUlid}/close`                                | Cierra la ejecución antes de tiempo (`state = closed`) aunque queden casos sin probar. Mientras la [configuración de ejecución](https://docs.probara.net/es/developers/api/project-run-settings/) `autoCompleteRun` del proyecto esté activa (el valor por defecto), una ejecución también se cierra sola en cuanto todos los casos tienen un resultado registrado mediante los endpoints de casos de ejecución. Un reporte de CI (`POST /api/v1/projects/{projectId}/reports`) nunca cierra la ejecución por sí solo: envía `options.close: true` o llama a `close`. Llamar a `close` en una ejecución ya cerrada o abortada devuelve `409 conflict` (el pestillo es de un solo sentido); una ejecución desconocida devuelve `404`.                                                                                                                                                                                                                                                                                                                                                                                          |
| `POST`          | `/api/v1/projects/{projectUlid}/runs/{runUlid}/clone`         | "Volver a ejecutar": crea una nueva ejecución **abierta** a partir de una ejecución origen cerrada (completada o abortada). Cuerpo `{ title: string, cloneAssignees?: boolean, statusFilter?: TestOutcomeStatus[] }` — `title` es obligatorio; `statusFilter` selecciona qué casos del origen se copian (omitido/vacío = todos). Generaliza la antigua ruta `rerun-failed`, que se elimina.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `GET`           | `/api/v1/runs/{runUlid}/case-keys`                            | Los casos de la ejecución como `{ caseDisplayId, automationKey }`, paginados por cursor; los puede leer un token de aplicación (ver [Casos de ejecución](https://docs.probara.net/es/developers/api/run-cases/#listar-las-claves-de-casos-de-una-ejecución)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `GET` / `POST`  | `/api/v1/runs/{runUlid}/cases`                                | Lista los casos de la ejecución; `POST` añade (y congela) más casos. Cada caso incluye `executedByApiToken`, el token de API que ejecutó su intento actual (solo informativo, nunca el responsable; ver [Casos de ejecución](https://docs.probara.net/es/developers/api/run-cases/#executedbyapitoken-no-es-un-responsable)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `POST`          | `/api/v1/runs/{runUlid}/cases/bulk-mark`                      | Cuerpo `{ caseUlids: string[1..500], status }` (`passed` \| `failed` \| `skipped` \| `blocked`). Una transacción atómica; un evento `run.cases_bulk_marked`; responde `{ affected, unchanged }`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `POST`          | `/api/v1/runs/{runUlid}/cases/bulk-assign`                    | Cuerpo `{ caseUlids, assigneeUlid: string \| null }`. Misma atomicidad y forma de respuesta; emite `run.cases_bulk_assigned`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         |
| `POST`          | `/api/v1/runs/{runUlid}/cases/bulk-retry`                     | Cuerpo `{ caseUlids }`. Reinicia los casos seleccionados (y sus pasos) a `untested`; emite `run.cases_bulk_retried`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  |
| `POST`          | `/api/v1/runs/{runUlid}/cases/bulk-remove`                    | Cuerpo `{ caseUlids }`. Quita casos del run con capturas previas; emite `run.cases_bulk_removed`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `GET` / `PATCH` | `/api/v1/runs/{runUlid}/cases/{runCaseUlid}`                  | `PATCH` define `assigneeUlid` / `status` / `elapsedMs`. Un cambio de estado marca todos los pasos y añade una fila de resultado. La respuesta de `PATCH` incluye `resultUlid` nullable (el intento añadido cuando cambió `status`; `null` en parches solo de asignado/tiempo). El detalle `GET` añade `linkedDefects[]` — defectos distintos vinculados vía cualquier intento del caso (`ulid`, `defectNumber`, `title`, `status` y `assigneeUlid`, que es `null` cuando el defecto no está asignado) — y los `parameters`, `resultSteps` y `links` que reportó el intento actual (ver [Casos de ejecución](https://docs.probara.net/es/developers/api/run-cases/#obtener-un-caso-de-una-ejecución)). `PATCH` rechaza con `403 run_case_assignee_locked` cuando la configuración `assigneeResultLock` del proyecto está activa y quien llama no es el asignado ni está exento (sin exención para tokens de API), y con `422 validation_failed` (`details.code: "elapsed_ms_required"`) cuando `timeTracking` es `required` y un marcado terminal omite `elapsedMs` — ver [Casos de ejecución](https://docs.probara.net/es/developers/api/run-cases/). |
| `POST`          | `/api/v1/runs/{runUlid}/cases/{runCaseUlid}/open`             | Autoasigna al usuario actuante cuando está sin asignar, nunca en una ejecución que creó un token de aplicación (un reporter de CI) ni cuando un token de API ejecutó el intento actual (ver [Casos de ejecución](https://docs.probara.net/es/developers/api/run-cases/#abrir-un-caso-solo-asigna-un-responsable-vacío)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
| `PATCH`         | `/api/v1/runs/{runUlid}/cases/{runCaseUlid}/steps/{stepUlid}` | Marca un paso. El caso conserva su propio estado: se fija con un resultado.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `GET`           | `/api/v1/runs/{runUlid}/results`                              | Historial de marcados de solo-adición; un caso puede aparecer varias veces, ordenado por `executedAt`. Cada fila indica quién la ejecutó: `executedByUlid` (un miembro) y `executedByApiToken` (`{ ulid, name, app }` para un resultado que escribió un token de API, si no `null`; ver [Quién lo ejecutó](https://docs.probara.net/es/developers/api/run-result-snapshots/#quién-lo-ejecutó)).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |

Todos los endpoints de mutación requieren rol **`member`** o superior (`viewer` → `403 forbidden`) y emiten eventos de auditoría. Una ejecución **abortada** siempre rechaza estas mutaciones con `409 conflict`. Una ejecución cerrada sin abortar las acepta por defecto (`allowResultsInClosedRuns` tiene por defecto `true`); una organización o proyecto que fije esa [configuración de ejecución](https://docs.probara.net/es/developers/api/project-run-settings/) en `false` restaura el comportamiento anterior, rechazándolas también en una ejecución cerrada.

Una ejecución automatizada es envío de resultados desde CI, así que crearla (`automated: true`) necesita un plan de pago: en una organización sin uno responde `403 forbidden` con cualquier credencial, mientras que una ejecución manual se sigue creando. Un [token de aplicación](https://docs.probara.net/es/developers/authentication/#tokens-de-aplicación) solo puede crear ejecuciones automatizadas.

### Solo casos aprobados en las ejecuciones

Cuando la [configuración de revisión](https://docs.probara.net/es/developers/api/project-review-settings/) de un proyecto
tiene `onlyApprovedInRuns` activada (plan Pro o Enterprise, con la revisión `enabled`), una ejecución solo
admite casos de prueba con `reviewStatus` `approved`. Los casos que indicas en `caseUlids` (crear,
añadir casos, reconciliar) se rechazan en bloque con `422 validation_failed`, `details.reason`
`cases_not_approved` y `details.notApprovedCaseUlids` con la lista. Los casos que una ejecución toma
por su cuenta — la selección de un plan cuando se omite `caseUlids`, o la ejecución de origen de un
clon — se filtran, y la respuesta incluye `notApprovedSkippedCount`. Consulta
[Casos de ejecución](https://docs.probara.net/es/developers/api/run-cases/#solo-casos-aprobados). Los reportes de CI no se
ven afectados.

### Vinculación de entorno en ejecuciones

Las ejecuciones pueden vincularse a una entidad [Entorno](https://docs.probara.net/es/developers/api/environments/) estructurada gestionada por el proyecto.

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

| Campo           | Notas                                                                                                                                                                                                                                                                                                |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `environmentId` | ULID opcional de un entorno que pertenezca al proyecto de la ejecución. Devuelve `404 not_found` si el ULID no resuelve a un entorno activo en el proyecto. Omítelo o envía `null` para dejar la ejecución sin entorno vinculado.                                                                    |
| `environment`   | El entorno por nombre, para herramientas de CI. Cuando es una cadena no vacía, el servidor busca o crea un entorno cuyo slug coincida con el valor slugificado y vincula la ejecución a él. Enviar `environmentId` y `environment` a la vez responde `422 validation_failed` y no crea la ejecución. |

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

| Campo           | Notas                                                                                                                                                                                                             |
| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `environmentId` | ULID opcional para cambiar o vincular el entorno de la ejecución. Envía `null` para desvincular. Devuelve `404 not_found` para ULIDs desconocidos. El campo `environment` de texto libre no se acepta en `PATCH`. |

**Campos añadidos a `RunResponse`**

| Campo             | Tipo                           | Notas                                                                                                         |
| ----------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------- |
| `environmentId`   | string (ULID) \| null          | ULID del entorno vinculado, o `null` si no hay vínculo.                                                       |
| `environment`     | `{ ulid, name, slug }` \| null | Resumen del objeto entorno vinculado, o `null` si no hay vínculo.                                             |
| `environmentName` | string \| null                 | **Deprecado.** Valor de texto libre de la columna `environment` heredada. Usa `environment.name` en su lugar. |

### Referencias por nombre en ejecuciones

Un [token de aplicación](https://docs.probara.net/es/developers/authentication/#tokens-de-aplicación) no lee nada salvo las claves de casos de una ejecución,
así que una herramienta de CI no puede buscar el ULID de un hito, un plan de pruebas o una
configuración. Por eso `POST /api/v1/projects/{projectId}/runs` y la ejecución que crea un
[reporte](#reportes-de-ci) aceptan cada referencia de una de dos formas, salvo el plan, que la
ejecución nueva de un reporte acepta solo por nombre (`plan`):

| Forma ULID                                | Forma por nombre | Con qué coincide la forma por nombre                                                                                                                                                                                   |
| ----------------------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `environmentId`                           | `environment`    | El slug de un entorno activo del proyecto; si ninguno coincide, se crea uno nuevo (ver [Vinculación de entorno en ejecuciones](#vinculación-de-entorno-en-ejecuciones)).                                               |
| `milestoneId`                             | `milestone`      | Un display ID `M-<n>` o, si no, el nombre exacto, sensible a mayúsculas, de un hito del proyecto (1–255 caracteres).                                                                                                   |
| `planUlid` (solo creación de ejecuciones) | `plan`           | Un display ID `PLAN-<n>` o, si no, el nombre exacto, sensible a mayúsculas, de un plan de pruebas del proyecto (1–200 caracteres). Igual que `planUlid`: la ejecución se siembra con los casos seleccionados del plan. |
| `configurationUlids`                      | `configurations` | `[{ "group": "Browser", "name": "Chrome" }]`: el nombre exacto, sensible a mayúsculas, de un grupo de configuraciones del proyecto y de un valor de ese grupo. Como máximo 20 entradas, cada grupo una sola vez.       |

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

- Un valor con forma de display ID (`M-3`, `PLAN-2`) siempre se lee como tal: nunca recurre al
  nombre.
- Los hitos, planes, grupos y valores de configuración eliminados nunca coinciden.
- Un hito, plan o configuración desconocido responde `422 validation_failed` con
  `details: { "field": "<milestone | plan | configurations>", "code": "unknown_reference" }`, y no
  se escribe nada — ni siquiera el entorno que la misma solicitud habría creado.
- Un plan cuya selección está vacía responde `422 validation_failed` con
  `details: { "field": "plan", "code": "empty_selection" }` cuando se envió como `plan`
  (`"field": "caseUlids"` cuando se envió como `planUlid`), y no se escribe nada — ni siquiera el
  entorno que la misma solicitud habría creado. Ninguna creación de ejecución rechazada escribe el
  entorno que nombra.
- Enviar las dos formas de una misma referencia (por ejemplo `milestoneId` y `milestone`) responde
  `422 validation_failed`; un `null` cuenta como no enviado.
- La ejecución nueva de un reporte acepta el plan solo como `plan`: un `planUlid` en ella es un
  campo desconocido y responde `422 validation_failed`. También acepta `description` (como máximo
  2000 caracteres), igual que la creación de ejecuciones.

### Filtros del listado de ejecuciones

`GET /api/v1/projects/{projectId}/runs` acepta paginación por página (`page`, `pageSize`) y estos filtros de servidor. Los filtros se componen con semántica AND y `total` refleja la población filtrada:

| Parámetro      | Notas                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `q`            | Búsqueda por subcadena sin distinguir mayúsculas en nombre de ejecución y entorno                                                                                                                                                                                                                                                                                                                                                                                                                                           |
| `status`       | Estado proyectado separado por comas: `in_progress`, `passed`, `failed`                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
| `env`          | Valor repetible. Repite `env` para semántica OR. Pasa el **ULID** del entorno para filtrar por entidad vinculada; pasa un texto libre para buscar en la columna `environment` heredada. Los valores ULID y de texto libre se componen con OR cuando se mezclan. Los valores pueden contener comas, así que no los dividas por coma.                                                                                                                                                                                         |
| `authorUlid`   | ULID de usuario del autor de la ejecución. Las ejecuciones creadas por tokens de API no coinciden con autores de usuario.                                                                                                                                                                                                                                                                                                                                                                                                   |
| `assigneeUlid` | ULID del usuario asignado por defecto, o `empty` para ejecuciones sin asignado por defecto.                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| `cf`           | Filtro repetible de campo personalizado, `<fieldUlid>:<valor[,valor…]>`. Los valores dentro de una misma entrada `cf` se combinan con OR; repite `cf` para un campo distinto y combinar con AND entre campos. Los campos de opción usan ULIDs de opción, `checkbox` usa `true`/`false`, `user_picker` usa un ULID de usuario, y el token literal `empty` coincide con ejecuciones sin fila almacenada para ese campo. La coincidencia solo considera filas almacenadas — un valor por defecto materializado nunca coincide. |

Ejemplo:

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

### Forma de `RunResponse`

Cada `RunResponse` (devuelto por `GET /runs`, `GET /runs/{runUlid}` y el cuerpo de `POST /runs`) incluye, además de la identidad de la ejecución, sus metadatos y los ejes `state`/`status`, los siguientes campos:

- `runNumber`, `displayId` — el número de la ejecución dentro del proyecto y su forma legible `R-{runNumber}` (por ejemplo `R-12`). Resuelve un display ID con `GET /api/v1/projects/{projectId}/runs/by-display-id/{displayId}` (ver [Búsqueda por display ID](#búsqueda-por-display-id)).
- `total`, `executed`, `passRate` — contadores de progreso heredados (se mantienen por compatibilidad).
- `counts` — desglose exacto de 5 bandas de `test_run_cases.status`: `{ passed, failed, blocked, skipped, untested }`. `Σ counts = total`. Úsalo para pintar la barra de resultados; no lo derives de `passRate`.
- `firstResultAt`, `lastResultAt` — epoch ms del primer y del último `test_results.executed_at` registrado en la ejecución; ambos `null` hasta que aterriza el primer resultado. Usa `lastResultAt − firstResultAt` para el tiempo de reloj transcurrido; para una ejecución en curso, usa `now − firstResultAt`.
- `totalDurationMs` — suma de `test_results.duration_ms` de la ejecución (los resultados sin duración medida contribuyen `0`).
- `author` — instantánea discriminada del actor que creó la ejecución, capturada al crearla para que la fila siga siendo renderizable tras eliminarse el usuario o el token. Una de:
  - `{ "kind": "user", "ulid": "<userUlid>", "displayName": "...", "avatarKey": <string|null> }`
  - `{ "kind": "api_token", "ulid": "<apiTokenUlid>", "name": "...", "app": "junit" | "playwright" | "jest" | "cypress" | "vitest" | "cucumber" | null }` — `app` nombra la aplicación de Integraciones de un [token de aplicación](https://docs.probara.net/es/developers/authentication/#tokens-de-aplicación), y es `null` para un token API.
  - `null` — filas heredadas que la migración no pudo recuperar; renderiza como un guion largo.
- `configurations` — arreglo de las etiquetas de configuración de la ejecución,
  cada una `{ ulid, configurationUlid, groupName, valueName }`.
  `configurationUlid` es `null` cuando el valor de configuración subyacente fue
  eliminado permanentemente; `groupName`/`valueName` son instantáneas tomadas
  al fijar la etiqueta, así que la combinación sigue siendo renderizable tras
  un renombrado o una eliminación. `[]` para una ejecución sin configuraciones.
  Presente en todo `RunResponse` — la lista de ejecuciones, el detalle y los
  elementos de ejecución de cada plan — nunca se omite.
- `source` — de dónde vienen los resultados de una ejecución de CI, `{ provider, repository, branch,
commit, commitAuthor, pullRequestNumber, workflow, ciRunId, ciRunAttempt, ciJob, buildUrl }` con
  todas las claves presentes (`null` si se desconoce), o `null` para una ejecución sin origen. Ver
  [Origen de la ejecución](#origen-de-la-ejecución).
- `githubBuild` (solo en `GET /runs/{runUlid}`) — el build de GitHub Actions de una ejecución
  lanzada desde Probara, o `null`. Ver la [API de GitHub](https://docs.probara.net/es/developers/api/github/#lanzar-un-workflow).

Las ejecuciones creadas desde la API de integradores (`Authorization: Bearer <apiTokenSecret>`) llevan automáticamente `author.kind = "api_token"`; las creadas desde una sesión de usuario llevan `author.kind = "user"`. La misma forma aparece en `GET /api/v1/runs/{runUlid}`.

### Origen de la ejecución

`POST /api/v1/projects/{projectId}/runs` (y la ejecución de un reporte de CI, ver
[Reportes de CI](#reportes-de-ci)) acepta un `source` opcional que registra de dónde vienen los
resultados de la ejecución:

```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"
}
```

- Todos los campos son opcionales y se recortan los espacios. `provider` es `github_actions` u
  `other`; `repository` es `propietario/nombre`; `branch`, `commitAuthor`, `workflow` y `ciJob`
  tienen entre 1 y 255 caracteres sin caracteres de control; `commit`, entre 1 y 64 caracteres
  imprimibles sin espacios (un hash o cualquier identificador de revisión), y un SHA hexadecimal de 7
  a 40 caracteres cuando `provider` es `github_actions`; `ciRunId`, entre 1 y 64 caracteres
  imprimibles sin espacios; `pullRequestNumber` y `ciRunAttempt` son enteros positivos; `buildUrl` es
  una URL `http` o `https` de 2048 caracteres como máximo. Cualquier otro valor responde
  `422 validation_failed` y no crea la ejecución.
- `null`, `{}` o un origen con todos sus campos en `null` no guardan ningún origen: la ejecución
  responde `source: null`.
- El origen se escribe **una sola vez**. `PATCH /api/v1/runs/{runUlid}` no lo acepta (`422`); la
  única escritura posterior es un reporte de CI que reutiliza una ejecución **sin** origen, que
  recibe el del reporte. Una ejecución que ya tiene uno lo conserva.
- La aplicación web muestra el origen bajo el título de la ejecución: repositorio, rama, commit
  abreviado, autor, pull request, workflow, intento y un enlace al build.
- Un origen de GitHub Actions con `repository` y `pullRequestNumber` hace que la ejecución comente
  sus resultados en ese pull request al cerrarse o abortarse, según lo permita el ajuste de
  [comentarios en pull requests](https://docs.probara.net/es/guides/integrations/github/#comentarios-en-pull-requests) del
  proyecto, mientras la instalación de la GitHub App del proyecto tenga acceso al repositorio.
- El enlace público para compartir una ejecución nunca incluye su origen: la URL del build puede
  nombrar un host de CI privado.

### Valores de custom fields en ejecuciones

Las definiciones de `custom_fields` aceptan `entity: "test_run"` además de `test_case` y `defect`. Los valores a nivel de ejecución se leen y escriben mediante un contrato dedicado, siguiendo las mismas convenciones que casos de prueba y defectos:

- `GET /api/v1/runs/{runUlid}` incluye un array `customFieldValues` con una entrada por cada campo `test_run` visible para el proyecto de la ejecución. Los valores no almacenados se materializan con el `defaultValue` del campo (o `null`). **El listado de ejecuciones (`GET /api/v1/projects/{projectId}/runs`) no incluye este array** — leerlo siempre requiere el endpoint de detalle, de modo que paginar ejecuciones nunca paga un costo por fila.
- `PUT /api/v1/runs/{runUlid}/custom-field-values` reemplaza atómicamente el conjunto de valores de la ejecución:

```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"
```

Un campo visible omitido del cuerpo hace que su fila almacenada se elimine; una fila almacenada para un campo que ya no es visible para el proyecto de la ejecución se deja intacta. Los valores vacíos / `null` eliminan la fila correspondiente. El endpoint devuelve `200` con el array embebido completo — la misma forma que devuelve `GET /api/v1/runs/{runUlid}`.

Requiere el permiso de ejecución de resultados (que tienen `owner`/`admin`/`member`); un token o sesión `viewer` recibe `403 forbidden` y ninguna fila se toca. Solo una ejecución **abortada** rechaza la escritura con `409 conflict`; una ejecución cerrada y no abortada sigue aceptándola — la misma regla de congelamiento que sigue cualquier otra mutación de ejecución.

Una escritura exitosa que cambia al menos un valor se pliega dentro del evento de auditoría `run.updated` ya existente de la ejecución (`metadata.customFieldChanges`); nunca emite un evento `custom_field` independiente. Reenviar un conjunto de valores idéntico al ya almacenado devuelve `200` sin ningún evento de auditoría nuevo.

No existe una ruta `PATCH .../custom-field-values/{fieldUlid}` de un solo campo para ejecuciones — usa `PUT` para cualquier escritura.

Para descubrir los ULID de los campos `test_run` visibles de un proyecto, llama a `GET /api/v1/orgs/{orgUlid}/custom-fields?entity=test_run&projectUlid={projectUlid}`.

**Valores al crear (`POST /api/v1/projects/{projectUlid}/runs`)**

El cuerpo de creación acepta un array opcional `customFieldValues` con la misma forma `{ fieldUlid, value }` que `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"
```

Un campo `test_run` obligatorio siempre resuelve a un valor al crear: envíalo explícitamente, u omítelo por completo y se persiste el `defaultValue` del campo (un campo obligatorio nunca puede definirse sin un valor por defecto no vacío, así que la creación nunca puede fallar por un valor obligatorio faltante). Un campo opcional omitido del cuerpo no guarda ninguna fila — su valor por defecto solo se materializa al leer, exactamente igual que una ejecución que nunca llamó a `PUT`. Un valor inválido en cualquier campo enviado (tipo incorrecto, opción desconocida, etc.) rechaza la creación **completa** con `422 validation_failed` — no se crea la ejecución ni se escribe ninguna fila de valor. Los valores se pliegan dentro del único evento de auditoría `run.created` de la ejecución (`metadata.customFieldChanges`); una ejecución creada sin campos de ejecución emite la misma forma de metadata que antes de que este campo existiera.

Clonar una ejecución (`POST .../runs/{runUlid}/clone`) copia los valores almacenados de la ejecución origen a la nueva ejecución, traducidos contra las definiciones de campos del **proyecto destino** — un valor almacenado para un campo que ya no es visible en el proyecto destino se descarta silenciosamente, nunca se rechaza. La copia se pliega dentro del evento `run.cloned` del clon; la ejecución origen nunca se modifica ni se vuelve a auditar.

## Reportes de CI

`POST /api/v1/projects/{projectId}/reports` registra un lote de resultados automatizados en una sola
llamada: es el endpoint que usan las herramientas de CI (un importador JUnit, un reporter de
framework) en lugar de orquestar la creación de la ejecución, el emparejamiento de casos y el
marcado con varios endpoints. `{projectId}` es el código del proyecto.

```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`** es `{ "ulid": "…" }` para añadir a una ejecución **abierta** del proyecto, o los campos
  de una ejecución nueva, con las mismas reglas que `POST /api/v1/projects/{projectId}/runs` —
  incluidas las formas por nombre, ver
  [Referencias por nombre en ejecuciones](#referencias-por-nombre-en-ejecuciones):
  `{ name, description?, environmentId? o environment?, milestoneId? o milestone?, plan?, configurationUlids? o configurations?, tags?, source? }`.
  Un `plan` (solo por nombre, sin `planUlid`) siembra la ejecución nueva con los casos seleccionados
  del plan; si su selección está vacía, responde `422` nombrando `plan` y no se escribe nada.
  Reutilizar una ejecución cerrada o abortada responde `409 conflict`; una ejecución de otro proyecto responde `404`.
- **`run.source`** (opcional en ambas formas) es el origen de CI de la ejecución, ver
  [Origen de la ejecución](#origen-de-la-ejecución). Una ejecución creada lo
  guarda. Una ejecución reutilizada lo recibe solo mientras no tenga ninguno, así que cada bloque
  puede enviar el mismo origen: gana el primero y ninguno se sobrescribe nunca.
- **`results`** lleva entre 1 y 500 entradas. Cada una necesita `caseDisplayId` o `automationKey`
  (o ambos) y un `status` (`passed` \| `failed` \| `skipped` \| `blocked`); `durationMs`, `notes`, un
  `executedAt` ISO, `parameters`, `steps`, `links` y `case` son opcionales (ver
  [Qué puede llevar una entrada](#qué-puede-llevar-una-entrada)). Envía suites más grandes en bloques
  de 500, reutilizando la ejecución que devolvió la primera llamada. Los campos desconocidos, o una
  entrada sin `caseDisplayId` ni `automationKey`, responden `422 validation_failed` sin escribir
  nada.
- **Emparejamiento**, por entrada: si hay `caseDisplayId`, decide él: empareja ese caso o la entrada
  queda sin emparejar, sin recurrir nunca a la clave. Cuando el caso emparejado no tiene clave de
  automatización y ningún otro caso tiene la `automationKey` de la entrada, el caso la adopta: así
  se vincula por primera vez un caso manual existente. Si no, la entrada se empareja por
  `automationKey`. Un caso creado antes en el mismo lote, o uno que acaba de adoptar la clave, se
  reutiliza, así que las entradas que comparten una clave crean un solo caso.
- **Creación de casos**: una clave desconocida que llega con `title` crea el caso bajo `suitePath`
  (nombres de suite recortados, como máximo 10 niveles) debajo de `options.suiteUlid` o de la raíz
  del proyecto, reutilizando las suites con el mismo nombre. `options.createMissingCases: false` lo
  desactiva. Un `options.suiteUlid` desconocido responde `404` sin escribir nada.
- **Casos existentes**: un caso archivado emparejado se restaura y registra el resultado. Para dejar
  de reportar una prueba, sáltala o elimínala en tu código. Un caso emparejado por `automationKey`
  cuya entrada trae `suitePath` se mueve ahí, debajo de `options.suiteUlid` o de la raíz del
  proyecto, cuando está en otro lugar, así que el lugar de la prueba en tu código decide su suite.
  Un caso emparejado por `caseDisplayId`, o gestionado por una sincronización Git, conserva su
  suite, y lo mismo todo caso cuando `options.createMissingCases` es `false`. Un reporte nunca cambia el título, los pasos ni los campos de un caso existente.
- **Registro**: cada entrada emparejada añade un resultado con su propio estado, duración, notas,
  `executedAt`, parámetros, pasos y enlaces; los casos emparejados que faltan en la ejecución se añaden, y cada caso de la
  ejecución conserva el último resultado recibido. Un reporte nunca completa la ejecución
  automáticamente, así que el siguiente bloque todavía puede llegar a ella; envía
  `options.close: true` en la última llamada para cerrarla.
- **Atómico e idempotente**: todas las escrituras (ejecución, suites, casos, casos de la ejecución,
  resultados, eventos de auditoría) se confirman juntas o no se confirma ninguna. Envía un
  `Idempotency-Key` para que un lote reintentado se reproduzca en lugar de registrarse dos veces.
  Cada resultado registrado cuenta para el
  [límite mensual de resultados de API](#uso-de-resultados-de-api-de-la-organización); un lote que el
  límite no puede cubrir se rechaza entero con `409 api_result_limit_exceeded`. Reutilizar un
  `Idempotency-Key` con otro cuerpo responde `422 validation_failed`. Las reproducciones se guardan
  para respuestas de hasta 64 KB, y el límite de 500 entradas y los `warnings` acotados mantienen cada
  reporte por debajo.
- **CI en paralelo**: varios jobs pueden reportar a la misma ejecución a la vez. Cada reporte se
  confirma contra la ejecución tal como está en ese momento, así que ningún caso se añade dos veces,
  las posiciones nunca chocan y dos jobs que crean la misma clave nueva comparten un caso (el
  segundo no lleva `created`). Un reporte cuya ejecución se cerró entretanto responde
  `409 conflict` sin escribir nada. Una vez confirmado, un reporte siempre responde `201`: si
  cerrar la ejecución falla después, la respuesta muestra la ejecución todavía `open`; ciérrala con
  `POST /api/v1/runs/{runUlid}/close`.
- Requiere los permisos de ejecución y de escritura de casos de prueba (`owner`/`admin`/`member`);
  un `viewer` recibe `403 forbidden`.
- **Jobs de GitHub Actions agrupados en una ejecución**: un reporte sin `ulid` de ejecución cuyo
  `run.source` tiene `provider: "github_actions"`, un `repository` y un `ciRunId` se añade a la
  ejecución del proyecto de ese repositorio, id de ejecución de CI e intento (`ciRunAttempt`, 1 si se
  omite), y solo la crea si no existe. Todos los jobs de una misma ejecución del workflow pueden enviar
  el mismo `run` y todos llegan a **una** ejecución. Esa ejecución sigue abierta hasta que un job
  final la cierra con `options.close: true` (con `results` vacío si se quiere) o con
  `POST /api/v1/runs/{runUlid}/close`. Un job que reporta después de que la ejecución se cerrara o se
  abortara recibe `409 ci_run_closed` con `details.runUlid`, y no se escribe nada. Volver a ejecutar
  el workflow (otro intento) crea su propia ejecución. Una ejecución lanzada desde Probara se nombra
  por su `ulid` en todos los intentos, porque GitHub vuelve a ejecutar un workflow con los mismos
  inputs: un reporte con ese `ulid` cuyo `run.source` nombra otro intento de la ejecución del
  workflow se añade a la ejecución propia de ese intento, creada por su primer reporte y con el
  nombre de la ejecución lanzada (como `Nightly E2E · attempt 2`). Lee la ejecución en el `run.ulid`
  de la respuesta. Probara cierra esa ejecución cuando el intento termina en GitHub. La petición del
  job final:

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

- **Solo planes de pago**: el envío de resultados desde CI está incluido en los planes de pago. En
  una organización sin uno, un reporte responde `403 forbidden` sin escribir nada, con cualquier
  credencial.
- Un [token de aplicación](https://docs.probara.net/es/developers/authentication/#tokens-de-aplicación) de la aplicación
  **JUnit XML** (la credencial de la CLI de Probara), de la aplicación **Playwright** (la
  credencial del reporter de Probara para Playwright), de la aplicación **Jest** (la credencial
  del reporter de Probara para Jest), de la aplicación **Cypress** (la credencial del reporter de
  Probara para Cypress), de la aplicación **Vitest** (la credencial del reporter de Probara para
  Vitest) o de la aplicación **Cucumber** (la credencial del reporter de Probara para Cucumber y de la
  sincronización Gherkin de la CLI de Probara) puede enviar reportes; la ejecución que crea para ellos debe
  ser automatizada.

### Qué puede llevar una entrada

Además de su estado, una entrada puede enviar lo que un reporter de framework sabe de la ejecución y
del caso que crea:

```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" }]
  }
}
```

| Campo               | Qué registra                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `contextParameters` | Los nombres de `parameters` que son contexto del reporter, como el `project` de Playwright o de Vitest, el `browser` de Cypress, o el `attempt` y el `repeat` de un reintento. Cada nombre debe ser uno que el resultado reporta, y aparece una sola vez; si no, la entrada responde `422`. El contexto se muestra en el resultado y nunca se convierte en un parámetro de un caso que el reporte crea.                                                                                                                                                                                                                     |
| `parameters`        | Los parámetros con los que se ejecutó, de nombre a valor, guardados con el resultado en el orden enviado. Como máximo 20; nombres y valores se recortan, un nombre tiene 1–100 caracteres y un valor como máximo 500. Dos nombres iguales una vez recortados, y el nombre `__proto__`, responden `422`.                                                                                                                                                                                                                                                                                                                     |
| `steps`             | Los pasos que ejecutó, como un árbol: cada paso es `{ action, status, keyword?, durationMs?, error?, expected?, data?, steps? }`, con `status` como el de la entrada, y `keyword` es la palabra clave Gherkin con la que se escribió el paso (`Given`, `When`, `Then`, `And`, `But` o `*`; Cucumber envía una por paso). Como máximo 200 pasos por entrada contando todos los niveles, anidados como máximo 10 niveles; `action` de 1–2000 caracteres, `expected` y `data` como máximo 2000, `error` como máximo 4000. El índice de un paso es su posición en un recorrido en profundidad y en preorden del árbol, desde 0. |
| `links`             | Enlaces sobre la ejecución — una incidencia, una página de un TMS, el log de un build — como `{ url, name? }`, guardados con el resultado en el orden enviado. Como máximo 20 por entrada; `url` es una URL absoluta `http:` o `https:` de como máximo 2048 caracteres y `name` tiene 1–255 caracteres, ambos recortados. Cualquier otro esquema (`javascript:`, `data:`, `file:`, `ftp:`) o una URL relativa responde `422` sin escribir nada.                                                                                                                                                                             |
| `case`              | Con qué empieza un caso que el reporte **crea**: `description`, `tags` (como máximo 50 nombres de 1–80 caracteres; los desconocidos se añaden al catálogo de etiquetas de la organización), `fields` y `steps` (como máximo 500 `{ action, expected?, data? }`, numerados en orden).                                                                                                                                                                                                                                                                                                                                        |

- **`case` solo se aplica al crear**: solo lo aplica la entrada que crea el caso — la primera entrada
  de una clave de automatización nueva, con su `title`. Las entradas que emparejan un caso existente,
  y las entradas posteriores de la misma clave, lo ignoran sin advertencia: un reporte nunca modifica
  un caso existente.
- **`fields`** nombra como máximo 50 campos, sin distinguir mayúsculas: una clave de campo de sistema
  (`priority`, `severity`, `type`, `layer`, `behavior`, `status`, `preconditions`, `postconditions`,
  `is_flaky`) o el título de un custom field de casos de prueba visible en el proyecto. Un valor de
  un campo de selección o de opción única nombra una opción (sin distinguir mayúsculas; `;` separa
  las opciones de una selección múltiple); una casilla es falsa con `false`, `0`, `no` o `n` y
  verdadera con cualquier otro valor; los demás campos toman el valor como texto, validado según el
  tipo del campo. Un campo o valor que no se resuelve — un nombre desconocido, una opción
  desconocida, un valor que el tipo del campo rechaza — **se omite y aparece en `warnings`**; nunca
  hace fallar el reporte. Un campo u opción que se elimina mientras se procesa el reporte se omite
  de la misma forma (`Field "Platforms" changed during the report and was skipped`); en una
  selección múltiple, se omite el campo completo si se eliminó cualquiera de sus opciones.
- **Un valor vacío se omite sin advertencia**: un valor que queda vacío tras recortarlo no asigna
  nada, tampoco en una casilla (nunca se guarda como falsa), y no añade ninguna advertencia, sea
  cual sea el nombre.
- **Totales por reporte**: entre todas sus entradas, un reporte lleva como máximo 10.000 pasos de
  resultado (`steps`, contando todos los niveles; `REPORT_RESULT_STEPS_MAX_TOTAL`), 10.000 pasos de
  caso (`case.steps`; `REPORT_CASE_STEPS_MAX_TOTAL`) y 1.000 nombres de etiquetas de caso
  (`case.tags`; `REPORT_CASE_TAGS_MAX_TOTAL`). Los pasos y las etiquetas de caso cuentan tanto si la
  entrada crea su caso como si no. Los enlaces no necesitan un total propio: 500 entradas de como máximo
  20 enlaces ya limitan un reporte a 10.000. Un reporte mayor responde `422 validation_failed` sin escribir
  nada, y el error indica el total y la cantidad del reporte — por ejemplo `a report carries at most
1000 case tag names across all entries (this one has 1001); send the entries in smaller chunks`.
  Divídelo en más bloques.
- **Adjuntos de pasos**: adjunta un archivo a un paso confirmándolo con el índice del paso como
  `stepIndex` (ver [Adjuntos de resultados](https://docs.probara.net/es/developers/api/result-attachments/)).
- Los parámetros, los pasos y los enlaces se leen como `parameters`, `resultSteps` y `links` en la
  [instantánea del intento](https://docs.probara.net/es/developers/api/run-result-snapshots/) y, para el intento actual del
  caso, en el [detalle del caso de ejecución](https://docs.probara.net/es/developers/api/run-cases/#obtener-un-caso-de-una-ejecución).
  La respuesta del reporte nunca los devuelve. La aplicación web muestra cada enlace como un enlace
  externo, con su `name` o, si no lo tiene, su URL.

### Parámetros de un reporte

El campo `parameters` de un resultado elige el caso de ejecución del caso de prueba que reporta, mediante los nombres que el caso declara:

- Un nombre que el caso declara selecciona el caso de ejecución con exactamente los valores que el resultado reporta para los nombres declarados. Un nombre que el caso no declara se queda en el resultado solo para mostrarlo, y nunca divide un caso de ejecución.
- Si la ejecución no tiene un caso de ejecución con esos valores, Probara añade uno, aunque el caso no liste esos valores. Un resultado que no reporta ninguno de los nombres declarados se asocia al caso de ejecución sin valores.
- Un caso que el reporte crea toma un parámetro `Examples` de los datos que reportan sus resultados: uno simple cuando nombran un parámetro, un grupo cuando nombran varios. Solo se crea cuando al menos dos conjuntos de datos distintos nombran los mismos parámetros. Los nombres de contexto (`contextParameters`) quedan fuera de ese parámetro.

Consulta [Casos de prueba parametrizados](https://docs.probara.net/es/guides/parameterized-test-cases/).

### Asignar los resultados fallidos

`options.assignFailedTo` indica por email quiénes revisan lo que el reporte deja en estado fallido:

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

- **Qué casos de ejecución**: cada caso de ejecución cuyo resultado en este reporte es `failed` —
  decide la última entrada del caso en el reporte — y que **no tiene responsable**. Se asignan por
  turnos entre los miembros, en el orden indicado y siguiendo el orden de las entradas: tres casos
  fallidos entre dos miembros van al primero, al segundo y de nuevo al primero. Los turnos vuelven
  a empezar por el primer miembro en cada reporte: una suite enviada en varios reportes a una misma
  ejecución empieza cada uno de ellos por el primer miembro.
- **Nunca se sobrescribe**: un caso de ejecución que ya tiene responsable (asignado por alguien, por
  el responsable predeterminado de la ejecución o por un reporte anterior) lo conserva y no consume
  turno. Los casos aprobados, omitidos y bloqueados nunca se modifican.
- **Emails**: de 1 a 20, sin espacios al inicio o al final y comparados sin distinguir mayúsculas; un
  email repetido cuenta una vez. Un email solo corresponde a un **miembro activo** de la
  organización que podría trabajar en el caso de ejecución: su rol ejecuta ejecuciones (owner, admin
  o member; un viewer o un rol personalizado no), y el proyecto le está abierto (en un proyecto
  privado, un owner o admin, o alguien con acceso concedido de forma individual o a través de un
  grupo; nunca alguien a quien se le revocó el acceso al proyecto). Así, un caso de un proyecto
  privado nunca se asigna, ni su ejecución se nombra en una notificación, a alguien ajeno a él. Los
  que no corresponden se omiten y la respuesta lleva una sola advertencia, siempre la primera de
  `warnings`, que solo los cuenta y nunca dice cuáles fallaron:
  `assignFailedTo: 1 of 2 emails did not match a member who can be assigned in this project`.
  Si ninguno corresponde, no se asigna nada. El recuento no oculta la pertenencia a quien llama: un
  reporte que indica un solo email averigua si ese email se puede asignar. Un token de aplicación es una
  credencial de la organización que emite un owner o un admin, y solo puede saber si un email que ya
  conoce se puede asignar en ese proyecto. Un valor que no es un email responde `422` sin escribir
  nada.
- **Nunca hace fallar el reporte**: la asignación se confirma en la misma transacción que los
  resultados, y la repetición con el mismo `Idempotency-Key` devuelve la respuesta guardada sin
  volver a asignar. La asignación de cada miembro queda en la auditoría como una asignación masiva
  (`run.cases_bulk_assigned`), atribuida al usuario o al token de aplicación del reporte, y el
  miembro recibe una notificación por ejecución, como en una asignación manual; nunca la recibe el
  usuario que envió el reporte.

### Respuesta

La respuesta `201` contesta cada entrada en el orden de la petición: `results[i]` es el resultado de
la entrada `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` solo aparece cuando el reporte omitió algo sin fallar, como un campo del caso que no pudo
resolver o que se eliminó mientras se procesaba el reporte, o emails de `assignFailedTo` que no se
pudieron asignar (esa advertencia va siempre primero). No tiene duplicados y lleva como máximo 20
elementos de como máximo 300 caracteres (4 KB en total); si se dejaron fuera más, el último
elemento los cuenta.

Una entrada sin emparejar no registra nada y lleva un `reason`: `case_not_found` (ningún caso tiene
el display ID ni la clave, y no se creó ninguno), `title_required` (se permitía crear,
pero la entrada no traía `title`) o `invalid_display_id` (mal formado, o el display ID de un caso de
otro proyecto). Usa cada `resultUlid` para adjuntar archivos con los endpoints de adjuntos de
resultados.

El mismo reporte está disponible para asistentes de IA como la herramienta MCP `submit_report`
(consulta la [referencia de herramientas MCP](https://docs.probara.net/es/developers/mcp-tools/)): su entrada es este
cuerpo de la petición más `project_id` y un `idempotency_key` opcional, y responde esta misma
respuesta.

## Sincronización de archivos Gherkin

`POST /api/v1/projects/{projectId}/gherkin-sync` hace que los casos sincronizados de un proyecto coincidan con los escenarios de los archivos `.feature` que analizó un job de CI. `{projectId}` es el código del proyecto. La CLI de Probara (`probara sync gherkin`) y la herramienta MCP [`sync_gherkin_features`](https://docs.probara.net/es/developers/mcp-tools/#sync_gherkin_features) envían esta petición. La [guía de sincronización](https://docs.probara.net/es/guides/syncing-feature-files-from-git/) explica las reglas para clientes.

El cuerpo lleva el `scope` de la sincronización y las funcionalidades con sus escenarios. `suiteUlid` indica la suite en la que se crean las suites de los archivos (la raíz del proyecto si se omite), y `dryRun: true` responde el mismo resultado sin escribir nada:

```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` tiene de 1 a 20 globs, por ejemplo `features/**/*.feature`. El `path` de cada feature debe coincidir con alguno, y solo pueden archivarse los casos sincronizados cuyo archivo coincide con `scope`.
- Una sincronización admite como máximo 1.000 escenarios. Cada escenario tiene su `automationKey` (`<ruta> > <Feature> > <Scenario>`), su `line`, sus `steps` con su `keyword` y, de forma opcional, su `background`, sus `examples` (para un Scenario Outline), su `rule` y su `url` (un enlace `https`).

Cada escenario se empareja en este orden. Una etiqueta `probara:<ID visible del caso>` lo vincula a ese caso, sea cual sea su clave. Si no, su `automationKey` encuentra el caso que la tiene. Si no, una clave nueva cuyo hash de contenido (sus pasos, Background y Examples) sea igual al de exactamente un caso sincronizado que desapareció en la misma sincronización mueve ese caso. Si no, el escenario crea un caso. Un caso sincronizado cuyo escenario ya no existe se archiva, y un escenario que coincide con un caso archivado lo restaura. Un caso manual que encuentra una clave o un vínculo se convierte en caso sincronizado. Los ejemplos están en la [guía de sincronización](https://docs.probara.net/es/guides/syncing-feature-files-from-git/#movimientos-renombres-y-vínculos).

La respuesta `200` tiene el resultado:

```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` tiene una entrada por escenario, en el orden de la petición, y después una por cada caso archivado. `action` es `created`, `updated`, `moved`, `restored`, `archived` o `unchanged`. `previousAutomationKey` se indica en un movimiento. `caseUlid` y `caseDisplayId` son `null` para un caso que crearía una sincronización con `dryRun`, y `line` es `null` para un caso archivado.

Los rechazos no escriben nada:

- `403 forbidden`: el plan no incluye el envío desde CI, que comparte la sincronización, o el rol no puede escribir casos de prueba ni ejecutar (un `viewer`).
- `404 not_found`: no existe el proyecto ni el `suiteUlid`.
- `422 validation_failed`: el cuerpo no cumple el esquema (un campo desconocido, un `path` que no es una ruta de `.feature` relativa al repositorio, más de 1.000 escenarios o 20 globs); el `path` de una feature no coincide con ningún `scope`; dos escenarios comparten `automationKey` (se nombran ambas líneas); o una etiqueta `probara:` nombra un caso inexistente o de otro proyecto, un escenario lleva dos de esas etiquetas, o dos escenarios vinculan un mismo caso.

Una sincronización escribe un evento de auditoría `test_case.synced` para el proyecto, con sus recuentos y su alcance, y un evento `test_case.archived` o `test_case.restored` por cada caso que archiva o restaura. Los casos que crea o actualiza no tienen evento propio, así que no envían `test_case.created` ni `test_case.updated` por [webhook](https://docs.probara.net/es/developers/webhooks/).

## Miembros de la organización

`GET /api/v1/orgs/{orgUlid}/members` lista los miembros de la organización con paginación por página. Añade `?status=active` para listar a los miembros que pueden iniciar sesión, `?status=deactivated` para listar a quienes un administrador desactivó, o deja `status` fuera para listar a todos. Cada fila incluye `deactivatedAt`: el instante, en milisegundos desde la época, en que un administrador desactivó al miembro, o `null` mientras el miembro está activo.

Los administradores desactivan a un miembro en lugar de eliminarlo. Un propietario o administrador (`org-members.manage`) llama a:

- `POST /api/v1/orgs/{orgUlid}/members/{userUlid}/deactivate` corta de inmediato el acceso del miembro a la organización. Se revocan sus tokens de API personales, sus concesiones de OAuth y MCP, sus sesiones de inicio de sesión único y sus accesos de emergencia a la organización, y se libera su puesto. La membresía, el rol, el acceso a proyectos, los grupos y la autoría se conservan.
- `POST /api/v1/orgs/{orgUlid}/members/{userUlid}/reactivate` devuelve el acceso y ocupa de nuevo un puesto. Requiere un puesto libre: sin uno, la solicitud responde `409 seat_limit_exceeded` y el miembro sigue desactivado. Los tokens y concesiones revocados no se restauran, así que el miembro necesita otros nuevos.

Ambas llamadas envían un cuerpo JSON (basta con `{}`), responden `200` con `{ "member": … }` y registran un evento de auditoría `member.deactivated` o `member.reactivated`. Un propietario no puede desactivarse y no puedes desactivarte a ti mismo (`409 conflict`), y un miembro que ya está en el estado solicitado recibe la misma respuesta.

`DELETE /api/v1/orgs/{orgUlid}/members/{userUlid}` ya no existe. Quien deja la organización sigue quitando su propia membresía con `POST /api/v1/orgs/{orgUlid}/memberships/leave`, y una cuenta borrada se elimina de todas las organizaciones a las que pertenecía.

Cuando un cambio a Free deja más miembros de los que caben en sus tres puestos, Probara desactiva el excedente automáticamente: primero los miembros más recientes, nunca el propietario. Esos eventos de auditoría llevan `metadata.reason` `seat_overflow`, y un miembro desactivado puede reactivarse cuando haya un puesto libre. Los visores ocupan puestos en Free; en un plan con visores gratuitos no los ocupan.

## Uso de resultados de API de la organización

`GET /api/v1/orgs/{orgUlid}/api-result-usage` informa los resultados contabilizados y autoritativos de la organización para el mes calendario **actual**, junto con su techo efectivo `apiResultsPerMonth` — las mismas cifras que aplica el límite mensual de resultados de API descrito arriba, nunca un conteo recalculado. Cualquier miembro autenticado de la organización puede llamarlo; no existe restricción de rol.

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

`periodKey` es una etiqueta opaca `YYYY-MM` (UTC) — trátala como un identificador, no como algo para analizar o calcular. Leer el uso nunca crea, reinicia ni avanza el contador, así que consultarlo repetidamente no tiene ningún efecto secundario sobre tu techo. Al comenzar el siguiente mes calendario, el reporte muestra `0` para el nuevo periodo automáticamente, sin ninguna acción del operador ni despliegue — reflejando el mismo reinicio que el límite de escritura ya realiza de forma perezosa en el siguiente resultado contabilizado.

## Uso de puestos (seats) de la organización

`GET /api/v1/orgs/{orgUlid}/seat-usage` informa los puestos usados de la organización y su límite efectivo de puestos — la misma definición de puesto usado que compara la aplicación del límite de puestos descrita arriba, nunca un conteo recalculado o derivado del listado de miembros. Cualquier miembro autenticado de la organización puede llamarlo; no existe restricción de rol.

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

Un miembro suspendido por el equipo de soporte sigue ocupando un puesto aquí, aunque el listado visible de miembros lo oculte — el reporte siempre coincide con la validación, nunca con el listado. Una invitación pendiente y no vencida reserva un puesto de la misma forma que lo hace para la validación; una invitación vencida que sigue en estado pendiente no reserva ninguno. Leer los puestos nunca modifica nada: no vence una invitación vencida, no revoca nada, ni rechaza por límite de puestos por sí misma. Un límite efectivo ilimitado se devuelve exactamente como lo resolvió el lector de entitlements. Un miembro desactivado no ocupa ningún puesto (véase [Miembros de la organización](#miembros-de-la-organización)).

## Exportación del registro de auditoría de la organización

`GET /api/v1/orgs/{orgUlid}/audit-events/export` descarga el registro de auditoría de la organización como un único artefacto CSV — una fila por evento de auditoría, ordenado cronológicamente — pensado para un revisor de cumplimiento o seguridad que necesita "el último trimestre de actividad del workspace, como archivo". Requiere membresía en la organización con rol `owner` o `admin`, **y** el entitlement `auditExportEnabled` (una capacidad del plan Enterprise). Ambos rechazos devuelven `403 { code: "forbidden" }` y son indistinguibles en el cable — quien llama no puede deducir si la organización tiene el entitlement probando su propio rol, ni al revés.

Los parámetros `from` y `to` son **obligatorios**, en ISO-8601 UTC con el designador `Z` (una fecha sin hora u con desplazamiento horario se rechaza). La ventana es semiabierta — `from` es inclusivo, `to` es exclusivo — así que dos llamadas consecutivas donde el `to` de la primera coincide con el `from` de la segunda nunca producen un duplicado ni un hueco en el instante límite. El rango no puede superar los 366 días; un parámetro faltante, una ventana invertida (`to <= from`) o un rango excesivo devuelven `422 { code: "validation_failed" }`.

Se calcula un conteo exacto de eventos que coinciden **antes** de leer ninguna fila. Una ventana cuyo conteo supera los 25.000 eventos se rechaza con `422 { code: "validation_failed" }`, con un mensaje que nombra tanto el conteo real como el techo — reduce la ventana y vuelve a intentarlo. La exportación tiene su propio límite de 10 llamadas por organización por hora, en un cupo independiente del límite de exportación de casos de prueba descrito arriba; superarlo devuelve `429 { code: "too_many_requests" }`.

La respuesta es `Content-Type: text/csv; charset=utf-8` con `Content-Disposition: attachment` que nombra un archivo que incluye la ventana solicitada, de modo que dos exportaciones nunca coincidan en una carpeta de descargas. Columnas: `created_at, event_ulid, action, actor_kind, actor_ulid, actor_label, entity_type, entity_ulid, entity_label, run_ulid, metadata`. Una celda cuyo valor de origen pudiera interpretarse como una fórmula de hoja de cálculo (que empiece con `= + - @`) se neutraliza con un `'` inicial antes de encomillarla. La columna `run_ulid` queda vacía para un evento que nunca estuvo asociado a una ejecución, contiene el ULID de la ejecución cuando esta todavía existe, y contiene el marcador literal `[run purged]` o `[run unknown]` para los dos casos en que la ejecución ya no existe pero el evento estaba (o podría haber estado) asociado a una ejecución. Un actor cuya identidad fue borrada se muestra como `[identity removed]` en `actor_label`, en lugar de cualquier dato personal resucitado. Exportar es una operación de solo lectura: nunca modifica ni elimina datos de auditoría, y llamarla no genera por sí misma un nuevo evento de auditoría.

## Changelog

- **Cambio incompatible — campos de texto enriquecido e imágenes insertadas.** Los campos de texto largo aceptan Markdown en `<campo>` o un documento en `<campo>Document`, uno por escritura, y las respuestas incluyen ambos (ver [Campos de texto enriquecido](#campos-de-texto-enriquecido)). Las imágenes van insertadas: prepáralas con `POST /api/v1/images:stage` y referencíalas en el texto. Se eliminan las galerías de imágenes de pasos, de comentarios, de ítems de pasos compartidos y de pasos de ejecución, junto con sus endpoints y herramientas MCP. Nombrar una lista `attachments` en un paso o en un comentario se rechaza. Los adjuntos de archivo de resultados y de defectos no cambian.

- **Sincronización Gherkin desde Git y resultados de Cucumber.** Nuevo `POST /api/v1/projects/{projectId}/gherkin-sync` hace que los casos sincronizados de un proyecto coincidan con archivos `.feature` (consulta [Sincronización de archivos Gherkin](#sincronización-de-archivos-gherkin)). Los casos de prueba incluyen `source` (`manual` o `git`) y, en un caso `git`, `sourceFile`. Un guardado que cambie un caso sincronizado responde `409 test_case_synced_read_only`. Los pasos de resultado reportados admiten `keyword`, y el `source` del historial de versiones incluye `git_sync`.

- **Cambio incompatible — `DELETE /api/v1/orgs/{orgUlid}/members/{userUlid}` se elimina.** Desactiva al miembro con `POST /api/v1/orgs/{orgUlid}/members/{userUlid}/deactivate` y reactívalo con `.../reactivate`. `GET /api/v1/orgs/{orgUlid}/members` acepta `?status=active|deactivated` y cada miembro incluye `deactivatedAt`. Consulta [Miembros de la organización](#miembros-de-la-organización).

- **Planes: Free, Pro y Enterprise.** Las claves de plan en los payloads de suscripción y de organización son `free`, `pro` y `enterprise`. `POST /api/v1/orgs/{orgUlid}/subscription/trial` acepta solo `tierKey` `pro`. SCIM responde `403` con `SCIM requires the Enterprise plan.` en cualquier otro plan, y la exportación del registro de auditoría requiere el plan Enterprise. Consulta [Facturación](https://docs.probara.net/es/guides/billing/).

- **Requisito de autenticación en dos pasos de la organización.** `GET /api/v1/orgs/current`
  incluye `requireTwoFactor`. Mientras es `true`, un miembro con sesión en la app web cuya cuenta no
  tiene autenticación en dos pasos recibe `403 organization_two_factor_required` en las rutas de la
  organización. Las claves de API, los tokens de app y los clientes MCP no se ven afectados. El
  propietario lo cambia desde **Workspace → Configuración**; consulta
  [Workspace](https://docs.probara.net/es/guides/workspace/).

- **Revisión de casos: acciones en lote y Asignadas a mí (Pro).** El nuevo
  `POST /api/v1/projects/{projectId}/test-case-reviews/bulk` abre una solicitud de revisión por caso
  (hasta 200) que revisa el caso tal como está: `currentVersionOnly` es `true`, y fusionarla marca el
  caso como `approved` sin escribir una revisión. El nuevo `POST .../test-case-reviews/bulk-decision`
  aprueba, pide cambios o rechaza hasta 50 solicitudes mediante las acciones individuales, con una
  `note` obligatoria para pedir cambios o rechazar. `POST .../{reviewUlid}/decline` acepta una `note`
  opcional, que se muestra como `declineNote`. El listado acepta `assignedToMe` y `needsMyReview`, y
  el resumen suma `needsMyReviewCount`. Las solicitudes de revisión llevan `currentVersionOnly` y
  `declineNote`. Nuevas herramientas MCP `bulk_create_test_case_reviews` y
  `bulk_decide_test_case_reviews`; `decline_test_case_review` acepta `note` y
  `list_test_case_reviews` los dos filtros nuevos. Consulta
  [Solicitudes de revisión de casos](https://docs.probara.net/es/developers/api/test-case-reviews/#enviar-casos-a-revisión-tal-como-están).

- **Revisión de casos: todas las vías de escritura y las acciones por MCP (Pro).** Con la revisión
  obligatoria, `POST /api/v1/projects/{projectId}/test-cases` y `PATCH /api/v1/test-cases/{caseUlid}`
  responden `202` con la solicitud de revisión que abrieron en lugar de `409 review_required`, y
  `POST .../imports/commit` abre una solicitud de revisión por caso: el informe de importación suma
  `counts.reviewRequestsCreated` y `reviewRequests`, y los códigos de fallo `review_already_open` y
  `review_proposal_invalid`. `GET .../exports?scope=filtered` acepta el filtro `reviewStatus`.
  Nuevas herramientas MCP `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` y `merge_test_case_review`; con la
  revisión obligatoria, `create_test_case` y `update_test_case` devuelven
  `{ sentToReview: true, message, review }`. Consulta
  [Revisión obligatoria de casos de prueba](#revisión-obligatoria-de-casos-de-prueba-202-y-review_required).

- **Revisión de casos: comentarios y notificaciones (Pro).** Nuevos endpoints
  `/api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}/comments` (listar, crear, editar,
  eliminar, fijar, desfijar) con las formas de los comentarios de casos de prueba, que registran
  `test_case_review.commented`, `comment_updated`, `comment_deleted`, `comment_pinned` y
  `comment_unpinned`. Cinco tipos de notificación en un nuevo grupo **Revisión**:
  `test_case_review.assigned`, `test_case_review.decided` (con la decisión en
  `payload.context.status`), `test_case_review.edited`, `test_case_review.comment_created` y
  `test_case_review.comment_mention`, con `entityType` `test_case_review`. Enviar un caso nuevo cuya
  suite o hito pertenece a otro proyecto ahora responde `422 validation_failed`, y dos envíos
  simultáneos para un mismo caso responden `409 review_already_open` al segundo. Consulta
  [Solicitudes de revisión de casos](https://docs.probara.net/es/developers/api/test-case-reviews/#comentarios-en-una-solicitud).

- **Revisión de casos: las acciones de Revisión (Pro).** Los nuevos
  `GET /api/v1/projects/{projectId}/test-case-reviews` (listado, con los filtros `status`, `kind`,
  `author` y `reviewer`), `GET .../test-case-reviews/summary`, `GET .../{reviewUlid}/versions` (la
  versión propuesta), `POST .../{reviewUlid}/approve`, `POST .../{reviewUlid}/unapprove`,
  `POST .../{reviewUlid}/request-changes`,
  `PATCH .../{reviewUlid}` (editar la propuesta), `PUT .../{reviewUlid}/reviewers`,
  `POST .../{reviewUlid}/decline` y `POST .../{reviewUlid}/merge`. Un cambio fusionado se guarda como
  una revisión del caso con `source` `review`. Cada acción registra un evento de auditoría
  `test_case_review.*` (`approved`, `changes_requested`, `edited`, `reviewers_updated`, `declined`,
  `merged`). La lectura de una solicitud suma `case`, `approvalCount`, `approvalsRequired`,
  `allowSelfMerge`, `conflict` y, por revisor, `current` y `note`. `PATCH .../review-settings` ahora
  conserva a un revisor predeterminado oculto porque perdió el acceso, y reordenar la misma lista de
  revisores ya no registra un evento de auditoría. Consulta
  [Solicitudes de revisión de casos](https://docs.probara.net/es/developers/api/test-case-reviews/).

- **Solicitudes de revisión por id visible (Pro).** El nuevo
  `GET /api/v1/projects/{projectId}/test-case-reviews/by-display-id/{displayId}` y la herramienta MCP
  `get_test_case_review_by_display_id` leen una solicitud por su id `RV-{n}`, el que ahora usan las
  URL de revisión en la aplicación. Consulta
  [Leer una solicitud de revisión](https://docs.probara.net/es/developers/api/test-case-reviews/#leer-una-solicitud-de-revisión).

- **Solicitudes de revisión de casos (Pro).** Los nuevos
  `POST /api/v1/projects/{projectId}/test-case-reviews` y
  `GET /api/v1/projects/{projectId}/test-case-reviews/{reviewUlid}`, y las herramientas MCP
  `create_test_case_review` y `get_test_case_review`, envían a revisión un caso nuevo o un cambio en
  un caso existente y leen una solicitud. El envío registra un evento de auditoría
  `test_case_review.created`. Mientras la revisión de un proyecto es obligatoria, las escrituras
  directas de casos responden `409 conflict` con `details.reason` `review_required`; consulta
  [Revisión obligatoria de casos de prueba](#revisión-obligatoria-de-casos-de-prueba-202-y-review_required).
  Los revisores predeterminados ahora también deben tener el permiso `test-cases.review`, o
  `PATCH .../review-settings` responde `422`. Consulta
  [Solicitudes de revisión de casos](https://docs.probara.net/es/developers/api/test-case-reviews/).

- **Configuración de revisión de casos (Pro).** Los nuevos
  `GET`/`PATCH /api/v1/projects/{projectId}/review-settings` y las herramientas MCP
  `get_project_review_settings` y `update_project_review_settings` leen y cambian la configuración
  de revisión de casos de prueba de un proyecto. Los cambios registran un evento de auditoría
  `project.review_settings_updated`. `GET /api/v1/orgs/current` incluye `caseReviewEnabled`: `true`
  cuando el plan incluye la revisión de casos (Pro o Enterprise), `false` en los demás casos. Los roles
  personalizados incorporan el permiso `test-cases.review`. Consulta
  [Configuración de revisión de casos](https://docs.probara.net/es/developers/api/project-review-settings/).

- **Integración con GitHub.** Nuevos endpoints para vincular issues de GitHub a casos de prueba,
  ejecuciones y defectos, crear issues desde defectos y lanzar workflows de GitHub Actions; ver la
  [API de GitHub](https://docs.probara.net/es/developers/api/github/). Los informes de trazabilidad aceptan repositorios de
  GitHub como fuente. El `source` de una ejecución incorpora `provider`, `repository`,
  `commitAuthor`, `pullRequestNumber`, `workflow`, `ciRunId`, `ciRunAttempt` y `ciJob`; los reportes
  de GitHub Actions se agrupan en una ejecución por intento de ejecución del workflow, y un reporte a
  un grupo cerrado responde `409 ci_run_closed`. `GET /runs/{runUlid}` incorpora `githubBuild`.
- **Los informes de trazabilidad reemplazan los endpoints de cobertura de Jira.** `GET /api/v1/projects/{projectId}/integrations/jira/coverage` y `GET /api/v1/projects/{projectId}/integrations/jira/unlinked-issues` se eliminan, y también las herramientas MCP `get_jira_coverage` y `list_jira_unlinked_issues`. Un informe de trazabilidad toma una instantánea de los requisitos de los proyectos de Jira y tipos de incidencia que elijas, y cada versión se genera en segundo plano. Consulta la [API de informes de trazabilidad](https://docs.probara.net/es/developers/api/traceability-reports/) y la [referencia de herramientas MCP](https://docs.probara.net/es/developers/mcp-tools/).

- **Consultas guardadas y PQL.** `POST /api/v1/query`, `GET /api/v1/query/schema` y los endpoints de consultas guardadas bajo `/api/v1/queries` (listar, crear, obtener, reemplazar, eliminar y ejecutar) están disponibles en los planes Pro y Enterprise. Consulta [Consultas PQL](https://docs.probara.net/es/developers/api/pql/) y [Consultas guardadas](https://docs.probara.net/es/developers/api/saved-queries/). Los cambios en consultas guardadas se registran en el registro de auditoría como eventos `saved_query.*`.
- **`GET /api/v1/orgs/current` incluye `managedPlan`.** La respuesta de la organización actual incorpora `managedPlan`: `"enterprise"` mientras un contrato Enterprise rige la organización, `"managed"` mientras lo hace un contrato de cortesía o interno, y `null` en otro caso. El campo es solo la etiqueta; el precio y las condiciones del contrato nunca se exponen. La facturación de autoservicio responde `409 { code: "conflict", details: { reason: "contract_active" } }` mientras cualquiera de las dos etiquetas esté presente.

- **Registros detrás del valor de un widget.** `GET /api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/records`
  y `list_dashboard_widget_records` listan, página por página, los casos de prueba, defectos,
  ejecuciones, resultados o suites detrás de un valor de un widget: un conteo, un conteo de creados,
  un grupo de una distribución o un intervalo de una serie temporal. El `total` es el valor que el
  widget muestra para ese selector. Consulta
  [Registros de un widget](https://docs.probara.net/es/developers/api/dashboard-widgets/#registros-de-un-widget).

- **Los grupos `other` de selección múltiple cuentan cada caso una vez.** En un widget de
  `distribution` sobre un campo `select_multi`, el grupo `other` cuenta los casos de prueba que
  tienen una opción agrupada, cada uno una sola vez. Antes, un caso que tenía dos opciones agrupadas
  se contaba dos veces, así que `other` podía ser mayor que la cantidad de casos que contenía.
  Consulta [Dashboard widgets](https://docs.probara.net/es/developers/api/dashboard-widgets/#distribución).

- **Valores de custom fields al crear un caso de prueba.** `POST /api/v1/projects/{projectId}/test-cases`
  y `create_test_case` aceptan un array opcional `customFieldValues`, de modo que un caso se crea
  con sus precondiciones, postcondiciones u otros campos en una sola llamada y una sola revisión.
  Consulta [Valores de custom fields en casos de prueba](#valores-de-custom-fields-en-casos-de-prueba).

- **Filtros de widgets de dashboards.** Los widgets de datos aceptan `filters` al crear y modificar
  un widget de dashboard con `POST` y `PATCH`, y con `create_dashboard_widget` y
  `update_dashboard_widget`; cada respuesta de widget los lista con una etiqueta por valor. Los
  campos que ofrece un widget dependen de lo que cuenta su métrica; un valor de filtro que no indica
  nada que puedas usar devuelve `422 validation_failed`. Ver
  [Widgets de dashboards](https://docs.probara.net/es/developers/api/dashboard-widgets/#filtros).

- **Quitar los proyectos visibles de un widget.** Al modificar un widget de dashboard con `PATCH` o
  con `update_dashboard_widget`, el `projectUlids` de un alcance `selected` ahora puede estar vacío,
  de modo que un editor puede quitar todos los proyectos que puede abrir mientras queden proyectos
  ocultos para él. Un alcance `selected` que se quedaría sin ningún proyecto devuelve
  `422 validation_failed`. Ver
  [Widgets de dashboards](https://docs.probara.net/es/developers/api/dashboard-widgets/#alcance-de-proyectos).

- **Datos de los widgets de distribución y de serie temporal.** El endpoint de datos de widgets y
  `get_dashboard_widget_data` ahora responden los widgets `distribution` (casos de prueba por valor
  de un campo personalizado, con un grupo `not_set`, `multiValue` en los campos de selección
  múltiple y `fieldRemoved` cuando el campo se eliminó) y `time_series` (intervalos por día, semana
  o mes a la medianoche de la zona horaria del perfil de quien llama, con un valor por intervalo
  para cada serie de la métrica) y `pass_rate` (un valor agregado de las ejecuciones cerradas en el rango, con sus conteos), en lugar de `422 unsupported widget type`. Consulta
  [Widgets de dashboards](https://docs.probara.net/es/developers/api/dashboard-widgets/#distribución).

- **Datos de widgets de dashboards.**
  `GET /api/v1/dashboards/{dashboardUlid}/widgets/{widgetUlid}/data` y la herramienta MCP
  `get_dashboard_widget_data` devuelven lo que muestra un widget `count` o `created_count`,
  calculado con el acceso a proyectos de quien llama. `scope` indica cuántos proyectos se contaron
  y cuántos quedaron ocultos, y el `range` de un widget con rango indica cuándo la retención del
  historial del plan lo recortó. Consulta
  [Widgets de dashboards](https://docs.probara.net/es/developers/api/dashboard-widgets/#datos-de-un-widget).

- **Widgets de dashboards.** Nuevos endpoints en `/api/v1/dashboards/{dashboardUlid}/widgets`
  listan, agregan, modifican, reordenan y quitan los widgets de un dashboard, de un catálogo cerrado
  (título de sección, conteo, conteo de creados, distribución, serie temporal), con herramientas MCP
  equivalentes como `create_dashboard_widget`. Los proyectos seleccionados de un widget se muestran
  siempre según el acceso a proyectos de quien llama, con un conteo de los ocultos. Consulta
  [Widgets de dashboards](https://docs.probara.net/es/developers/api/dashboard-widgets/).

- **Los dashboards públicos no tienen accesos de grupos.**
  `POST /api/v1/dashboards/{dashboardUlid}/access/groups` y la herramienta MCP
  `add_dashboard_access_group` rechazan un dashboard público con `409 conflict`, como ya ocurría con
  un acceso `granted` de un miembro. Hacer público un dashboard con
  `PATCH /api/v1/dashboards/{dashboardUlid}/access` ahora también quita sus accesos de grupos y
  registra un evento `group_revoked` por cada uno. Consulta
  [Dashboards](https://docs.probara.net/es/developers/api/dashboards/#hacer-un-dashboard-público-o-privado).

- **Acceso a dashboards en una sola llamada.** `POST /api/v1/dashboards` y `create_dashboard`
  aceptan `members` y `groups` para un dashboard privado, con acceso otorgado en el mismo paso en
  que se crea el dashboard. El nuevo `PUT /api/v1/dashboards/{dashboardUlid}` y la herramienta MCP
  `replace_dashboard_settings` establecen juntos el nombre, la descripción, la visibilidad, los
  miembros con acceso y los grupos de un dashboard: se aplica todo o nada, y los accesos `revoked`
  se conservan tal como están. Consulta
  [Dashboards](https://docs.probara.net/es/developers/api/dashboards/#reemplazar-la-configuración).

- **Dashboards privados desde su creación.** `POST /api/v1/dashboards` y la herramienta MCP
  `create_dashboard` aceptan un `accessMode` opcional (`public` de forma predeterminada, o
  `private`), así un dashboard pensado como privado nunca queda visible para el resto de la
  organización. El evento de auditoría `dashboard.created` registra la visibilidad con la que se
  creó. Consulta [Dashboards](https://docs.probara.net/es/developers/api/dashboards/#crear).

- **Acceso a dashboards.** Los nuevos endpoints en `/api/v1/dashboards/{dashboardUlid}/access`
  hacen un dashboard público o privado y administran los accesos de miembros (`granted` o `revoked`)
  y de grupos, con herramientas MCP equivalentes como `update_dashboard_access` y
  `set_dashboard_access_member`. Solo quien creó el dashboard y los propietarios y administradores de
  la organización pueden consultarlos o modificarlos. Cada cambio registra un evento de auditoría
  `dashboard.access_changed`. Consulta [Dashboards](https://docs.probara.net/es/developers/api/dashboards/#acceso).

- **Dashboards.** Los nuevos endpoints `/api/v1/dashboards` crean, listan, obtienen, renombran y
  eliminan los dashboards de la organización (planes Pro y Enterprise), junto con las herramientas MCP
  `list_dashboards`, `get_dashboard`, `create_dashboard`, `update_dashboard` y `delete_dashboard`.
  Un dashboard que no puedes ver responde `404`; uno que puedes ver pero no creaste responde `403`
  ante cambios, salvo que seas propietario o administrador de la organización. Consulta
  [Dashboards](https://docs.probara.net/es/developers/api/dashboards/).

- **Indicador de plan para dashboards.** `GET /api/v1/orgs/current` incluye `dashboardsEnabled`:
  `true` cuando el plan de la organización incluye dashboards (Pro y Enterprise), `false` en los demás
  casos.

- **Imágenes que solo conservan revisiones anteriores.** `GET /api/v1/orgs/{orgUlid}/attachments`
  las lista con `surface` `test_case_revision` y un campo nuevo, `testCase` (`null` en las demás
  superficies); `attachments:bulk-delete` las elimina, y eliminar ahí un `step_attachment` también lo
  quita de todas las revisiones, liberando sus bytes al momento. Las instantáneas de las revisiones
  marcan esas imágenes como `deleted`, y una restauración las indica en `skipped` con `kind` `image`.
  Consulta [Historial de versiones](#historial-de-versiones-revisions). Los dos enums suman un valor
  (`surface` `test_case_revision`, `skipped[].kind` `image`): un cliente que decide según ellos debe
  aceptar valores que no conoce, porque `/api/v1` agrega valores a sus enums sin cambiar de versión.

- **El historial de versiones requiere Pro o Enterprise.** En los demás planes,
  `GET /api/v1/test-cases/{caseUlid}/revisions`, `GET .../revisions/{revision}` y
  `POST .../revisions/{revision}/restore` (y las herramientas MCP `list_test_case_revisions`,
  `get_test_case_revision` y `restore_test_case_revision`) responden `403 forbidden`. Guardar sigue
  creando una revisión en todos los planes. `GET /api/v1/orgs/current` incluye `caseHistoryEnabled`.
  Consulta [Historial de versiones](#historial-de-versiones-revisions).

- **Restaurar una revisión de un caso.**
  `POST /api/v1/test-cases/{caseUlid}/revisions/{revision}/restore` devuelve un caso a una revisión
  anterior como una revisión nueva (`source` `restore`) e indica en `restore.skipped` lo que no se
  pudo recuperar. Herramienta MCP `restore_test_case_revision`. Consulta
  [Restaurar una revisión](#restaurar-una-revisión).

- **Historial de versiones de un caso.** `GET /api/v1/test-cases/{caseUlid}/revisions` lista las
  revisiones guardadas de un caso, de la más reciente a la más antigua (paginación por cursor), y
  `GET .../revisions/{revision}` devuelve una con su `snapshot`. Herramientas MCP
  `list_test_case_revisions` y `get_test_case_revision`. Consulta
  [Historial de versiones](#historial-de-versiones-revisions).

- **Configurar campos personalizados requiere un plan de pago.** En Free,
  `POST /api/v1/orgs/{orgUlid}/custom-fields` y `PATCH .../custom-fields/{fieldUlid}` (incluidos los
  campos de sistema) responden `403 forbidden`. Listar campos,
  `DELETE .../custom-fields/{fieldUlid}`, `POST .../custom-fields/{fieldUlid}/reset` y toda escritura
  de valores de campos funcionan en todos los planes. `GET .../custom-fields` incluye
  `customFieldsEnabled`, `true` cuando la organización puede crear y editar campos.

- **El token que ejecutó un resultado.** Cada resultado (`GET /api/v1/runs/{runUlid}/results`, el
  `result` de `GET /api/v1/runs/{runUlid}/results/{resultUlid}` y el enlace público) lleva
  `executedByApiToken`: `{ ulid, name, app }` para un resultado que escribió un token de API —
  `app` es el reporter de CI de un token de aplicación, `null` para un token de API personal — y
  `null` para un resultado registrado en la aplicación o una vez eliminado el token. Antes, un
  resultado que reportó un token de aplicación no nombraba a ningún ejecutor. Ver
  [Quién lo ejecutó](https://docs.probara.net/es/developers/api/run-result-snapshots/#quién-lo-ejecutó).

- **Claves de casos de una ejecución para reporters de CI.** `GET /api/v1/runs/{runUlid}/case-keys`
  lista los casos que están ahora en una ejecución como `{ caseDisplayId, automationKey }`
  (`automationKey` es `null` si el caso no tiene una), paginados por cursor (`limit` por defecto 50,
  máximo 200). Es la primera lectura que puede hacer un token de aplicación, para que un reporter de
  framework ejecute solo las pruebas vinculadas a los casos de una ejecución existente; la ejecución
  de otra organización responde `404 not_found`. Ver
  [Listar las claves de casos de una ejecución](https://docs.probara.net/es/developers/api/run-cases/#listar-las-claves-de-casos-de-una-ejecución).

- **Asignar los resultados fallidos.** Un reporte de CI acepta `options.assignFailedTo` (de 1 a 20
  emails de miembros): cada caso de ejecución que el reporte deja en `failed` y sin responsable se
  asigna por turnos a los miembros, empezando de nuevo por el primero en cada reporte; nunca se
  sobrescribe un responsable existente, y un email que no es de un miembro activo cuyo rol ejecuta
  ejecuciones y que tiene acceso al proyecto solo se cuenta en `warnings`. Ver
  [Asignar los resultados fallidos](#asignar-los-resultados-fallidos).

- **Enlaces en los resultados reportados.** Una entrada de un reporte de CI acepta `links`
  (`[{ url, name? }]`, como máximo 20, solo URLs absolutas `http:`/`https:`; cualquier otro esquema
  responde `422`). Se guardan con el resultado y se devuelven como `links` en la instantánea del
  intento y, para el intento actual, en el detalle del caso de ejecución. Ver
  [Qué puede llevar una entrada](#qué-puede-llevar-una-entrada).

- **Reporters de framework: pasos, parámetros, campos del caso y referencias por nombre.** Una
  entrada de un reporte de CI acepta `parameters`, un árbol `steps` y `case` (descripción,
  etiquetas, campos y pasos que solo se aplican cuando la entrada crea el caso); la respuesta puede
  llevar `warnings`, y un reporte lleva como máximo 10.000 pasos de resultado, 10.000 pasos de caso
  y 1.000 nombres de etiquetas de caso. `POST /api/v1/projects/{projectId}/runs` acepta
  `environment`, `milestone`, `plan` y `configurations` por nombre, y la ejecución nueva de un
  reporte los acepta igual (el plan solo como `plan`); enviar las dos formas de una referencia
  responde `422` (antes `environmentId` tenía prioridad sobre `environment`), un plan enviado como
  `plan` con la selección vacía responde `422` nombrando `plan`, y una creación de ejecución
  rechazada ya no crea el entorno que nombra. La confirmación de adjuntos de un resultado acepta
  `stepIndex`; la instantánea del intento devuelve `parameters` y `resultSteps`, y el detalle del
  caso de ejecución los devuelve para el intento actual. Los tokens de la nueva aplicación **Playwright** envían reportes como los de
  **JUnit XML**. Ver [Qué puede llevar una entrada](#qué-puede-llevar-una-entrada) y
  [Referencias por nombre en ejecuciones](#referencias-por-nombre-en-ejecuciones).

- **Los tokens de aplicación envían resultados desde CI.** Un token de aplicación de la organización
  (`probara_app_…`), creado desde la tarjeta **JUnit XML** en **Workspace → Integraciones**,
  pertenece a la organización y no a una persona, y solo puede hacer las cinco llamadas que hace la
  CLI de Probara; todo lo demás, `/mcp` incluido, responde `403 forbidden`. El envío de resultados
  desde CI necesita ahora un plan de pago: en Free, `POST /api/v1/projects/{projectId}/reports` y la
  creación de ejecuciones automatizadas responden `403 forbidden` con cualquier credencial. Las
  formas `api_token` del `author` de ejecuciones y planes y del `actor` de auditoría ganan `app`, y
  los elementos de adjuntos de la organización ganan `uploaderApp`. Consulta
  [Tokens de aplicación](https://docs.probara.net/es/developers/authentication/#tokens-de-aplicación).

- **Las ejecuciones registran su origen de CI.** `POST /api/v1/projects/{projectId}/runs` y el
  `run` de un reporte de CI aceptan un `source: { branch?, commit?, buildUrl? }` opcional; toda
  respuesta de ejecución incluye `source` (`null` si no hay). Se escribe una sola vez:
  `PATCH /api/v1/runs/{runUlid}` no lo acepta. Consulta
  [Origen de la ejecución](#origen-de-la-ejecución).

- **Una sola llamada registra una ejecución de CI.** `POST /api/v1/projects/{projectId}/reports`
  crea o reutiliza una ejecución, empareja hasta 500 resultados automatizados con casos de prueba
  por display ID o clave de automatización, crea los casos que faltan y registra un resultado por
  entrada, de forma atómica y con `Idempotency-Key`. Consulta [Reportes de CI](#reportes-de-ci).

- **Las organizaciones que nadie abre durante 12 meses ahora se programan para eliminarse.** Cuando
  ningún miembro abre una organización en la aplicación web durante 12 meses, Probara programa su
  eliminación con el período de gracia habitual de 14 días, así que
  `403 { code: "organization_pending_deletion" }` y un `pendingDeletion` no nulo en
  `GET /api/v1/orgs/current` pueden aparecer ahora sin que lo solicite una persona propietaria ni el
  soporte de Probara. Las peticiones con una clave de API o un token OAuth (MCP incluido) no cuentan
  como actividad y no la cancelan; sí la cancela un miembro que abre la organización en la aplicación
  web. Las organizaciones con una suscripción de pago que se va a renovar nunca se programan así.
  Consulta
  [Organizaciones que nadie abre durante 12 meses](https://docs.probara.net/es/guides/delete-organization/#organizaciones-que-nadie-abre-durante-12-meses).

- **Las ejecuciones y los hitos tienen IDs legibles.** Toda respuesta de ejecución incluye ahora
  `runNumber` y `displayId` (`R-12`), y toda respuesta de hito `milestoneNumber` y `displayId`
  (`M-3`). Resuélvelos con `GET /api/v1/projects/{projectId}/runs/by-display-id/{displayId}` y
  `GET /api/v1/projects/{projectId}/milestones/by-display-id/{displayId}` (ver
  [Búsqueda por display ID](#búsqueda-por-display-id)). Los enlaces a ejecuciones en los correos de
  notificación apuntan ahora a `/projects/{code}/runs/R-{n}`. Cambio aditivo: los endpoints por ULID
  no cambian.

- **Toda escritura de adjuntos ahora cuenta para la cuota de almacenamiento de la organización.** Las
  escrituras de pasos (`PUT /api/v1/test-cases/{caseUlid}/steps`,
  `POST /api/v1/projects/{projectId}/test-cases` y `PATCH /api/v1/test-cases/{caseUlid}` con adjuntos
  de pasos), la creación y edición de comentarios en casos de prueba y defectos
  (`POST`/`PATCH .../comments`), los resultados de pasos de ejecución
  (`PATCH /api/v1/runs/{runUlid}/cases/{runCaseUlid}/steps/{stepUlid}/result`), la clonación
  (`POST /api/v1/test-cases/{caseUlid}/clone`, `POST /api/v1/test-cases/bulk-clone`,
  `POST /api/v1/suites/{suiteUlid}/clone`) y `POST /api/v1/projects/{projectId}/avatar` ahora pueden
  responder `413 { code: "storage_quota_exceeded" }`, como ya lo hacían las subidas de adjuntos de
  resultados y de defectos. Una solicitud rechazada no cambia nada, con una excepción: si dos guardados simultáneos de pasos o
  de comentarios compiten por los últimos bytes libres, el rechazado puede conservar sus cambios que no
  son archivos (texto de pasos, cuerpo del comentario, un caso nuevo). Una solicitud que no añade
  archivos, o cuyo cambio neto libera espacio, nunca se rechaza. Eliminar de forma permanente un caso
  de prueba o un proyecto, y quitar pasos, ahora libera el espacio de sus archivos. Las herramientas
  MCP que llaman a estos endpoints devuelven el mismo error.

- **La propiedad de una organización ahora se transfiere, y cada organización tiene exactamente un
  propietario.** `POST /api/v1/orgs/{orgUlid}/ownership-transfer` con `{ "userUlid": "…" }` convierte
  a otro miembro actual en propietario y a la persona propietaria que llama en `admin`, en un solo
  paso, y devuelve `{ previousOwner, newOwner }`. Solo la persona propietaria actual puede llamarlo
  (de lo contrario, `403 { code: "forbidden" }`); apuntar a uno mismo o a un miembro que ya es
  propietario responde `422 { code: "validation_failed" }`, un miembro desconocido responde `404`, un
  cambio concurrente responde `409 { code: "conflict" }`, y un destino `viewer` necesita un puesto
  libre (`409 { code: "seat_limit_exceeded" }`). La herramienta MCP `transfer_org_ownership` lo expone.
  **Cambio incompatible: `PATCH /api/v1/orgs/{orgUlid}/members/{userUlid}` ahora rechaza
  `role: "owner"`** con `422 { code: "validation_failed" }` para cualquier llamador, incluida la
  persona propietaria; usa la transferencia en su lugar.

- **Las organizaciones se pueden programar para eliminarse, y quedan en solo lectura mientras tanto.**
  Una persona propietaria programa la eliminación con `POST /api/v1/orgs/{orgUlid}/deletion` y la
  cancela con `DELETE` sobre la misma ruta, ambas solo con cookie de sesión: ninguna clave de API ni
  token OAuth puede llamarlas. Durante los 14 días de gracia, cualquier escritura en la organización
  responde el nuevo `403 { code: "organization_pending_deletion" }`, también con claves de API y por
  MCP, y `GET /api/v1/orgs/current` incorpora `pendingDeletion` (`{ requestedAt, scheduledFor }` o
  `null`). Aceptar una invitación a una organización en ese estado se rechaza con el mismo código.

- **Un contrato activo ahora gobierna el plan, así que la facturación de autoservicio responde `409`
  mientras dure.** Mientras una organización tiene un contrato activo, cada mutación de facturación de
  autoservicio se rechaza con `409 { code: "conflict", details: { reason: "contract_active" } }` antes
  de cualquier llamada al proveedor de facturación:
  `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` y
  `DELETE /api/v1/orgs/{orgUlid}/subscription/cancel`. Las lecturas y el portal del cliente
  (`POST /api/v1/orgs/{orgUlid}/subscription/portal`) siguen disponibles. El reporte de suscripción
  incluye `managedPlan`, `{ label, tierKey, committedSeats, endsAt }`, mientras un contrato gobierna
  la organización. `label` es `"enterprise"` o `"managed"`, y `managedPlan` es `null` en otro caso. El
  catálogo solo lista los niveles publicados; un checkout o un cambio de plan a cualquier otro nivel
  responde `422 { code: "validation_failed" }`.

- **Ahora se puede cancelar un cambio programado de la suscripción desde la API.**
  `DELETE /api/v1/orgs/{orgUlid}/subscription/pending-change` descarta un cambio programado para
  aplicarse al final del período (una reducción de puestos, o un cambio de plan diferido a la
  renovación) y deja vigente el compromiso actual, devolviendo el mismo reporte de suscripción que
  devuelven las demás mutaciones de esta superficie. Cuando no hay nada programado responde
  `409 { code: "conflict" }` sin enviar ningún cambio al proveedor de facturación. Requiere
  `org-settings.manage`.

- **Ahora se acepta cualquier compromiso de puestos positivo, y se retira el rechazo dedicado por
  uso superior.** **Cambio que rompe compatibilidad — se elimina el rechazo
  `409 { code: "seat_commitment_below_usage" }`** de
  `POST /api/v1/orgs/{orgUlid}/subscription/checkout` y de
  `PATCH /api/v1/orgs/{orgUlid}/subscription/seats`. Ahora se acepta cualquier compromiso de
  puestos positivo; cuando los puestos comprometidos quedan por debajo de los puestos usados de la
  organización, Probara degrada a los miembros no propietarios más recientes (primero las
  invitaciones pendientes) al rol gratuito `viewer` en lugar de rechazar el cambio.

- **Las configuraciones de ejecución ahora se pueden leer y modificar, a nivel de proyecto y de
  organización.** `GET`/`PATCH /api/v1/projects/{projectId}/run-settings` y
  `GET`/`PATCH /api/v1/orgs/{orgUlid}/run-settings` exponen doce configuraciones que gobiernan el
  marcado, la escritura en ejecuciones cerradas y los valores predeterminados de creación de
  runs. Once se resuelven mediante `proyecto → organización → valor por defecto del código`;
  `defaultAssigneeUserId` es exclusiva del proyecto y no tiene nivel de organización. Junto con
  esto se publican tres cambios de comportamiento relacionados: (1) una ejecución cerrada sin
  abortar ahora acepta por defecto ediciones de notas de resultado y stage/commit/delete de
  adjuntos de resultado (`allowResultsInClosedRuns` tiene por defecto `true`) — una organización
  que prefiera el comportamiento anterior, más estricto, puede fijarlo en `false`; (2) marcar un
  caso de ejecución ahora rechaza con `403 { code: "run_case_assignee_locked" }` cuando la
  configuración `assigneeResultLock` del proyecto está activa y quien llama no es el asignado ni
  está exento, **sin exención para tokens de API**; (3) marcar un caso de ejecución con un estado
  terminal ahora rechaza con `422 validation_failed`
  (`details.code: "elapsed_ms_required"`) cuando la configuración `timeTracking` del proyecto es
  `required` y la solicitud omite `elapsedMs` — el marcado en bloque y el envío de resultados en
  bloque están exentos de este último rechazo. Consulta
  [Configuración de ejecución](https://docs.probara.net/es/developers/api/project-run-settings/) y
  [Casos de ejecución](https://docs.probara.net/es/developers/api/run-cases/).

- **Ahora se pueden crear invitaciones en bloque, hasta 50 direcciones por solicitud.**
  `POST /api/v1/orgs/{orgUlid}/invitations/bulk` acepta
  `{ emails: string[], role, locale?, grantAllPrivateProjects? }` — un solo rol y un solo idioma
  aplican a TODA la lista, nunca por dirección. `emails` acepta como máximo 50 entradas; una
  dirección repetida en la misma lista se evalúa una sola vez, en el orden de su primera
  aparición. La respuesta es siempre `{ results: [{ email, status }] }`, una entrada por cada
  dirección distinta enviada, clasificada en `created`, `duplicate_pending`, `already_member` o
  `invalid_email`. El techo de invitaciones pendientes de la organización y el límite de puestos
  se evalúan cada uno una sola vez, contra la cantidad de direcciones que realmente se crearían,
  nunca por dirección, y pueden seguir rechazando la solicitud completa con los mismos errores
  `409` de conflicto o límite de puestos que ya devuelve el endpoint de invitación individual.
  `grantAllPrivateProjects` (por defecto `false`; también aceptado en el endpoint de invitación
  individual) es una **intención de acceso a proyectos privados**, no una concesión inmediata:
  cuando es `true`, la persona invitada recibe una fila `granted` en cada proyecto privado **en el
  momento en que acepta**, reproducida de forma asíncrona a partir de la intención guardada en la
  invitación — nunca evaluada en el momento de invitar. Es un no-op para los roles `owner`/`admin`,
  ya que esos roles ya sortean la lista de acceso de proyectos privados y no ganarían nada con una
  concesión explícita. La app web muestra esto como la casilla **Acceso** en las pestañas Individual
  y Masiva del diálogo de invitación: presente y **deshabilitada con un motivo explícito** cuando el
  rol seleccionado ya sortea la lista de acceso, de modo que el control nunca se oculta — solo
  queda inerte donde otorgarlo no haría nada.

- **Ahora se puede leer el resultado final de entrega de correo de una invitación, y se retira el
  código de error dedicado a destinatario suprimido.** Los elementos de
  `GET /api/v1/orgs/{orgUlid}/invitations` ganaron `emailDeliveryStatus`: `null` mientras el correo
  de la invitación sigue en cola o ya fue entregado, `"suppressed"` si la dirección del
  destinatario es permanentemente no entregable, o `"failed"` si la entrega se reintentó hasta el
  techo de reintentos de la cola. Esto es solo un modelo de lectura — el correo de invitación se
  entrega desde una cola interna después de la respuesta, así que ningún endpoint de invitación
  tiene un veredicto de entrega que reportar mientras su solicitud sigue abierta. **Cambio que
  rompe compatibilidad — se elimina el conflicto dedicado
  `409 { code: "email_recipient_suppressed" }`** tanto de la creación como del reenvío de
  invitaciones; un destinatario permanentemente no entregable ya no aparece como un código de error
  distinto en ninguno de los dos endpoints — ambos ahora responden su mismo estado de éxito
  tolerante de siempre (`201`/`200`) en cualquier resultado de envío, y el resultado se reporta
  después a través de `emailDeliveryStatus`.

- **Ahora se pueden agregar en bloque las asignaciones de proyectos de un grupo, en el plano de grupo.**
  `POST /api/v1/groups/{userGroupUlid}/projects` asigna uno o varios proyectos a un grupo en una
  sola solicitud (`{ projectUlids: string[] }`, hasta 200, `.min(1)`) — una operación nueva junto a
  los endpoints de grupo del plano de proyecto ya existentes, no un reemplazo de ellos. **No lleva
  ninguna barrera de rol de organización propia**: la autorización corre por proyecto, dentro de la
  misma transacción, con la misma regla que ya aplica
  `POST /api/v1/projects/{projectId}/access/groups`. La escritura es todo-o-nada — un proyecto no
  resoluble (`404`) o no autorizado (`403`) en cualquier parte del lote rechaza la solicitud
  completa y no confirma nada, incluso un proyecto anterior en el lote para el que el llamante
  **sí** estaba autorizado. Los ULID repetidos se deduplican a una sola asignación; un proyecto ya
  asignado nunca es un error y nunca bloquea el resto del lote. Ver
  [Grupos de usuarios](https://docs.probara.net/es/developers/api/user-groups/).

- **Ahora se pueden agregar miembros de un grupo en lote, en una sola solicitud.**
  `POST /api/v1/groups/{userGroupUlid}/members` ahora también acepta `{ userUlids: string[] }`
  junto a la forma existente `{ userUlid: string }` — la misma operación y la misma ruta, no un
  endpoint nuevo. Un cuerpo que incluya ambas claves, o ninguna, se rechaza con
  `422 { code: "validation_failed" }`. La escritura es todo-o-nada: un `userUlid` que ya dejó la
  organización (o nunca fue miembro) en cualquier posición del arreglo rechaza la solicitud
  **completa** con `404 not_found` y no agrega a nadie, y una solicitud que no agregaría ninguna
  membresía nueva (todos los listados ya son miembros) responde `409 { code: "conflict" }` —
  igual que el comportamiento existente de un solo miembro. Los ULID repetidos se deduplican
  silenciosamente. Cada miembro efectivamente agregado sigue emitiendo su propio evento
  `user_group.member_added`; un ULID filtrado (ya miembro) no emite ninguno. Ver
  [Grupos de usuarios](https://docs.probara.net/es/developers/api/user-groups/).

- **Ahora se pueden incorporar las asignaciones de proyectos de un grupo de forma atómica al crearlo.**
  `POST /api/v1/groups` acepta un arreglo opcional `projectUlids`, siguiendo la misma convención que el
  ya existente `memberUserUlids`: la fila del grupo y cada asignación incorporada se confirman o
  fallan todas juntas, los duplicados se deduplican silenciosamente, y un ULID de proyecto desconocido
  rechaza la creación completa con `404 not_found`. Una asignación incorporada nunca se rechaza por
  apuntar a un proyecto público. Ninguno de los dos campos se acepta en
  `PATCH /api/v1/groups/{userGroupUlid}` — una clave desconocida rechaza toda la solicitud. Ver
  [Grupos de usuarios](https://docs.probara.net/es/developers/api/user-groups/).

- **Ahora se pueden asignar grupos de usuarios a la lista de acceso de un proyecto.**
  `GET`/`POST /api/v1/projects/{projectId}/access/groups` y
  `DELETE /api/v1/projects/{projectId}/access/groups/{userGroupUlid}` reflejan el plano y la
  regla de autorización de la lista de acceso individual, y
  `GET /api/v1/groups/{userGroupUlid}/projects` reporta los proyectos a los que está asignado un
  grupo, filtrados a lo que el miembro que llama puede descubrir por sí mismo. `projectCount` en
  `GET/POST /api/v1/groups` y `GET /api/v1/groups/{userGroupUlid}` ya no es siempre `0` — ahora
  reporta el conteo real de asignaciones del grupo. Ver
  [Grupos de usuarios](https://docs.probara.net/es/developers/api/user-groups/) y
  [Acceso a proyectos](https://docs.probara.net/es/developers/api/project-access/).

- **Ya está disponible un reporte de puestos (seats) con alcance de organización.**
  `GET /api/v1/orgs/{orgUlid}/seat-usage` informa los puestos usados de la organización y su
  límite efectivo de puestos — la misma definición de puesto usado que compara la aplicación
  del límite de puestos, nunca un conteo recalculado o derivado del listado de miembros.
  Disponible para cualquier miembro autenticado de la organización, sin restricción de rol.
  Leerlo nunca modifica nada.

- **Ya está disponible un endpoint de exportación del registro de auditoría de la
  organización.** `GET /api/v1/orgs/{orgUlid}/audit-events/export` descarga el registro de
  auditoría de la organización como CSV para una ventana obligatoria y acotada (máximo 366
  días). Restringido por rol de organización (`owner`/`admin`) Y por el entitlement
  `auditExportEnabled` — ambos rechazos son `403 forbidden` e indistinguibles en el cable. Se
  calcula un conteo exacto antes de leer ninguna fila; una ventana con más de 25.000 eventos se
  rechaza con `422 validation_failed`, nombrando tanto el conteo real como el techo. Limitado a
  10 exportaciones por organización por hora, de forma independiente al límite de exportación de
  casos de prueba. La exportación nunca modifica ni elimina nada.

- **Ya está disponible un reporte de uso de resultados de API con alcance de
  organización.** `GET /api/v1/orgs/{orgUlid}/api-result-usage` informa los resultados
  contabilizados con autoría de máquina de la organización durante el mes calendario actual,
  junto con su techo efectivo `apiResultsPerMonth`. Disponible para cualquier miembro
  autenticado de la organización, sin restricción de rol. Reporta el valor propio del
  contador autoritativo — nunca un conteo recalculado escaneando resultados — y leerlo nunca
  crea, reinicia ni avanza el contador. Al comenzar el siguiente mes calendario, el reporte
  muestra `0` para el nuevo periodo sin ninguna acción del operador ni despliegue.

- **Ahora se aplica el límite mensual de resultados de API de la organización.** Marcar un caso
  de ejecución (`PATCH /api/v1/runs/{runUlid}/cases/{runCaseUlid}` con `status`), `bulk-mark` y
  `bulk-submit-result` ahora rechazan una solicitud que llevaría los resultados contabilizados
  con autoría de máquina de la organización durante el mes calendario actual más allá de su
  `apiResultsPerMonth` efectivo, con `409 { code: "api_result_limit_exceeded" }`. Solo se
  contabiliza una solicitud autenticada con un token de API o un token de acceso OAuth; un
  llamador con cookie de sesión (incluida la propia aplicación web del producto) nunca se mide
  ni se rechaza por este motivo. Una solicitud masiva se rechaza **por completo** — nunca se
  aplica parcialmente. El rechazo no lleva encabezado `Retry-After` y no revela ni tu techo ni
  tu uso actual; es distinto de `429 too_many_requests` (un limitador de ráfagas) y del `409
conflict` que ya devuelve una ejecución cerrada. El valor por defecto documentado es el techo
  mensual del plan Free (5000 resultados contabilizados); una integración por debajo de ese
  volumen no observa ningún cambio, y un plan de nivel superior o un override explícito sube el
  techo de una organización específica, sin ningún despliegue, y el techo se reinicia
  automáticamente al comenzar el siguiente mes calendario.

- **Ahora se aplica el límite de tasa del plan de API de la organización.** Cualquier solicitud a `/api/v1` autenticada con un token de API o un token de acceso OAuth ahora cuenta contra el techo por minuto del plan de tu organización. Superarlo responde `429 { code: "too_many_requests" }` con un encabezado `Retry-After` que indica los segundos enteros restantes hasta que se reinicie la ventana actual de 60 segundos — espera ese tiempo y la solicitud idéntica se admite, sin necesidad de más cálculo de reintento. Este techo se aplica **únicamente** al tráfico autenticado por credencial de máquina: un llamador con cookie de sesión (incluida la propia aplicación web del producto) nunca se rechaza por este motivo. El valor por defecto documentado es de 60 solicitudes por minuto; un plan de nivel superior o un override explícito sube el techo de una organización específica y desbloquea la solicitud idéntica previamente rechazada de inmediato, sin ningún despliegue. Una llamada `tools/call` de `/mcp` que despacha una o más solicitudes secundarias a `/api/v1` cuenta cada solicitud secundaria despachada por separado — y además — de los límites de ráfaga de `/mcp` ya existentes, que permanecen sin cambios.

- **El límite de proyectos ahora alcanza a los proyectos que la organización ya tiene.** Una
  organización por encima de su límite efectivo de proyectos mantiene abiertos solo los creados
  más recientemente; todos los demás quedan bloqueados, y cualquier lectura o escritura sobre un
  proyecto bloqueado responde `403 { code: "project_locked" }`, por `/api/v1` y por MCP, tanto con
  una sesión como con una clave de API. `DELETE /api/v1/projects/{projectId}` sigue funcionando —
  es la única operación que sirve un proyecto bloqueado, y eliminar uno reabre el siguiente más
  antiguo en cuanto el total vuelve a estar dentro del límite. El bloqueo no elimina nada:
  `GET /api/v1/projects` sigue devolviendo todos los proyectos, cada uno con `lockedAt`
  (milisegundos epoch, o `null`), y subir el límite borra todas las marcas y reabre los proyectos
  sin cambios. Un enlace público de una ejecución que pertenece a un proyecto bloqueado se colapsa
  en el mismo `404 { code: "not_found" }`, byte a byte, que un token desconocido, para que un
  enlace válido no sirva para detectar el estado. Esto sustituye el comportamiento solo-en-creación
  descrito en la entrada de abajo.
- **Ahora se aplica el límite de proyectos de la organización.** `POST /api/v1/projects` ahora rechaza una solicitud que llevaría los proyectos usados de la organización más allá de su límite efectivo, con `409 { code: "project_limit_exceeded" }`, distinto del `409 { code: "conflict" }` que el mismo endpoint ya devuelve por un id de proyecto duplicado. El valor por defecto documentado es el techo de proyectos del plan Free (3); un plan de nivel superior o un override explícito sube el límite y desbloquea la solicitud idéntica sin ningún despliegue. En su momento, ningún proyecto existente se veía afectado por un límite reducido y solo se
  rechazaba la creación siguiente; eso ya no es así — ver la entrada sobre el bloqueo de proyectos
  más arriba.

- **Invitar a una dirección que ya es miembro ahora se rechaza.** `POST /api/v1/orgs/{orgUlid}/invitations` ahora responde `409 { code: "conflict" }` cuando la dirección invitada ya tiene una membresía en esa organización, para **cualquier** rol, incluido `viewer`. La operación correcta para alguien que ya está dentro de la organización es un cambio de rol (`PATCH /api/v1/orgs/{orgUlid}/members/{userUlid}`), no una invitación. Antes solo se rechazaba una segunda invitación **pendiente** para la misma dirección, así que una dirección cuya invitación anterior ya había sido aceptada podía volver a invitarse — y mientras esa invitación seguía pendiente, esa persona contaba dos veces contra el límite de puestos de la organización. La verificación está acotada a la organización que invita: una dirección que es miembro de _otra_ organización sigue siendo invitable.

- **Ahora se aplica el límite de puestos (seats) de la organización.** `POST /api/v1/orgs/{orgUlid}/invitations` (para un rol distinto de `viewer`) y `PATCH /api/v1/orgs/{orgUlid}/members/{userUlid}` (al ascender a un `viewer` a un rol distinto de `viewer`) ahora rechazan una solicitud que llevaría los puestos usados de la organización más allá de su límite efectivo, con `409 { code: "seat_limit_exceeded" }`. El rol `viewer` nunca consume un puesto y nunca se rechaza. Aceptar una invitación pendiente nunca se rechaza por límite de puestos — solo convierte una reserva ya contabilizada.

- **Potencialmente disruptivo — El `PATCH` de casos de prueba ahora exige el rol de escritura en el servidor.** `PATCH /api/v1/test-cases/{caseUlid}` (y `PATCH /defects/{defectUlid}`) ahora rechazan a un caller con rol `viewer` con `403 forbidden` antes de cualquier escritura, igualando el control ya aplicado a los valores de custom fields y a otros endpoints de escritura. Una API key con alcance `viewer` que antes tenía éxito en estas dos rutas ahora recibe `403`.

- **Breaking — Las ejecuciones se cierran solas; se elimina Reopen; `rerun-failed` se generaliza en `clone`.** Ahora una ejecución se cierra sola automáticamente en el instante en que todos los casos tienen resultado — no requiere llamada del cliente; el pestillo es de un solo sentido: nada dentro de una ejecución cerrada vuelve a abrirla. `POST /api/v1/runs/{runUlid}/reopen` se **elimina** (`404`). `POST /api/v1/projects/{projectUlid}/runs/{runUlid}/rerun-failed` se renombra a `.../clone` y se generaliza: el cuerpo ahora es `{ title: string, cloneAssignees?: boolean, statusFilter?: TestOutcomeStatus[] }` (antes `{ defaultAssigneeUlid? }`, que solo clonaba casos fallidos/bloqueados/sin probar/reintentados). Una ejecución cerrada y no abortada ahora acepta `PATCH /runs/{runUlid}`, agregar/quitar/marcar/reintentar casos y reconciliar — solo una ejecución **abortada** permanece congelada de forma terminal (`409 conflict`).

- **Ejecuciones — vinculación estructurada de entorno.** `POST /api/v1/projects/{projectUlid}/runs` y `PATCH /api/v1/runs/{runUlid}` ahora aceptan `environmentId` (ULID). Las respuestas de ejecuciones exponen un objeto `environment: { ulid, name, slug } | null` y `environmentId`. La cadena `environment` de texto libre sigue aceptándose en la creación por compatibilidad, pero no debe usarse en integraciones nuevas. `GET .../runs/environments` se mantiene pero está deprecado; filtra con `env=<environmentUlid>` en su lugar. Ver [API de entornos](https://docs.probara.net/es/developers/api/environments/).

- **Breaking — La ingesta de resultados ahora es un registro de solo-adición.** Se **elimina** `PUT /api/v1/runs/{runUlid}/results/{caseUlid}` (upsert idempotente). Registra resultados marcando casos de la ejecución (`PATCH /api/v1/runs/{runUlid}/cases/{runCaseUlid}`) o pasos; `GET /api/v1/runs/{runUlid}/results` ahora devuelve el historial completo de marcados (varias filas por caso) con `executedByUlid`. Las ejecuciones ganan `state` (`open`/`closed`), `description`, `defaultAssigneeUlid` y contadores de progreso.

- **Proyectos — `PATCH` / `DELETE` restringidos a owner/admin.** `member` y `viewer` reciben `403 forbidden` al mutar metadatos del proyecto. Usa token o sesión de propietario o administrador para renombrar el código del proyecto o eliminarlo.

- **Breaking — Los campos de clasificación de los casos se mueven a custom-field-values.** `POST`/`PATCH` sobre `/api/v1/projects/{projectId}/test-cases` y `/api/v1/test-cases/{caseUlid}` ya no aceptan `priority`, `severity`, `status`, `type`, `layer`, `behavior`, `isFlaky`, `preconditions` ni `postconditions`. Léelos desde el array `customFieldValues` en la respuesta del caso; escríbelos vía `PUT /api/v1/test-cases/{caseUlid}/custom-field-values`. Los valores del enum siguen siendo las opciones sembradas en los custom fields del sistema correspondientes.
