# Defects API

> Create, list, update, and delete project-scoped defects over the integrator API.

Defects are project-scoped bug records with per-project sequential numbers (`defectNumber`, shown as `D-<n>` in the UI). The reporter is always derived from the authenticated actor on create and cannot be set in the request body.

## Create

`POST /api/v1/projects/{projectId}/defects`

`{projectId}` is the project **code** (for example `ACME`).

Requires the defect write permission (held by `owner`/`admin`/`member`); a `viewer` receives `403 forbidden`.

### Request body

| Field               | Required | Notes                                                                                                                                                                                                                                                                                                                                                                                             |
| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `title`             | yes      | Non-empty string                                                                                                                                                                                                                                                                                                                                                                                  |
| `description`       | no       | Markdown text, or `descriptionDocument` (see [Rich text fields](https://docs.probara.net/en/developers/api-reference/#rich-text-fields))                                                                                                                                                                                                                                                                                  |
| `assigneeUlid`      | no       | Organization member ULID                                                                                                                                                                                                                                                                                                                                                                          |
| `tags`              | no       | String array                                                                                                                                                                                                                                                                                                                                                                                      |
| `milestoneUlid`     | no       | Milestone in the same project                                                                                                                                                                                                                                                                                                                                                                     |
| `customFieldValues` | no       | Array of `{ fieldUlid, value }` writes for visible defect fields. The seeded **Severity** and **Priority** system fields are required with org defaults (`major` / `medium` option ULIDs); omit unchanged defaults and the server persists them. Other omitted keys are satisfied at create time when the field has a non-empty stored default; otherwise create returns `422 validation_failed`. |

`reporter`, `resolvedAt`, and `closedAt` are rejected if sent by the client.

### Example

```bash
curl -sS -X POST \
  -H "Authorization: Bearer $PROBARA_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"title":"Login fails with expired password"}' \
  "https://app.probara.net/api/v1/projects/ACME/defects"
```

## List

`GET /api/v1/projects/{projectId}/defects`

Page-based pagination (`page`, `pageSize`), newest first. Response shape matches other table list endpoints (`items`, `page`, `pageSize`, `total`), where `total` counts every defect matching the active filters and `q`. Each list item includes the defect fields plus `occurrenceCount`, `lastSeenAt` (aggregated from linked run attempts), and `systemFieldValues` (severity/priority system entries with option enrichment only). When `fields` is omitted, list items do **not** include `customFieldValues`.

Pass `fields` as a comma-separated list of defect custom-field ULIDs visible in the project to embed `customFieldValues` on each item (stored value or materialized default, same shape as the detail endpoint). Requesting a field outside the project scope returns `422 validation_failed`.

### Query filters

Filters compose with AND semantics across parameters and are evaluated server-side.
The set-valued parameters `severity`, `priority`, and `assigneeUlid` take a
comma-separated set of ULIDs and OR-combine **within** each parameter; a single value
is accepted as a one-element set, so older single-value URLs keep working.

| Parameter      | Notes                                                                                                                                                             |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status`       | Comma-separated set, e.g. `open,in_progress`                                                                                                                      |
| `severity`     | Comma-separated set of option ULIDs of the org's `defect_severity` system field (OR within; matches defects holding any)                                          |
| `priority`     | Comma-separated set of option ULIDs of the org's `defect_priority` system field (OR within)                                                                       |
| `assigneeUlid` | Comma-separated set of organization member ULIDs (OR within); a ULID that resolves to no member is ignored, and the filter matches nothing only when none resolve |
| `q`            | Title substring, or defect number via `D-<n>` or a bare integer                                                                                                   |
| `aging`        | Open-defect age bucket: `d0_7`, `d7_14`, `d14_30`, `d30plus` (by `created_at`)                                                                                    |
| `fields`       | Comma-separated custom-field ULIDs to embed per item (`customFieldValues`)                                                                                        |
| `cf`           | Repeatable custom-field filter: `<fieldUlid>:<value[,value…]>` (see below)                                                                                        |

Invalid `status` values return `422 validation_failed`.

#### Custom-field filters (`cf`)

Repeat the `cf` query parameter once per field. Multiple `cf` entries AND-compose. Within one field, comma-separated values OR-compose.

Supported field types: `select`, `select_multi`, `checkbox`, and `user_picker`. Other types (including `paragraph`) return `422 validation_failed`. Filters match **stored value rows only** — a materialized default on read does not satisfy an option filter until persisted.

| Value token      | Meaning                                                                             |
| ---------------- | ----------------------------------------------------------------------------------- |
| Option ULID      | Defect stored with that option (`select`) or containing it (`select_multi`)         |
| `true` / `false` | Checkbox stored value                                                               |
| Member ULID      | `user_picker` stored member                                                         |
| `empty`          | No stored row for the field (including defects that would show a default on detail) |

Example — defects in Staging **or** Production (same field, comma OR):

```http
GET /api/v1/projects/ACME/defects?cf=01HENVFIELD:01HSTAGING,01HPROD
```

Example — defects with no Environment value **or** Staging, **and** checkbox Risk accepted:

```http
GET /api/v1/projects/ACME/defects?cf=01HENVFIELD:empty,01HSTAGING&cf=01HRISKFIELD:true
```

Example — embed Environment and Owner columns:

```http
GET /api/v1/projects/ACME/defects?fields=01HENVFIELD,01HOWNERFIELD
```

## Dashboard metrics

`GET /api/v1/projects/{projectId}/defects/metrics`

Single response for the defects list strip and sidebar badge. All figures use the
active organization and project scope; cross-tenant requests return `404 not_found`.

| Field            | Definition                                                                                                                                                                              |
| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `totalOpen`      | Defects in `open` or `in_progress`                                                                                                                                                      |
| `openBySeverity` | Object with `options` (array of `{ optionUlid, systemKey, name, icon, color, count }` in option order, zero counts included) and `unset` (open defects with no severity value row)      |
| `aging`          | Open population bucketed by age since `created_at`: `d0_7`, `d7_14`, `d14_30`, `d30plus`                                                                                                |
| `reopenRate`     | Distinct defects with ≥1 `defect.reopened` audit event ÷ distinct defects ever resolved (`defect.status_changed` with `to` of `resolved` or `closed`); `null` when the denominator is 0 |
| `mttrMs`         | Mean `resolved_at − created_at` over currently `resolved`/`closed` defects with non-null `resolved_at`; `null` when none                                                                |
| `byMilestone`    | Open population per milestone (`ulid`, `name`, `count`) plus `unassigned`                                                                                                               |

## Run-linked defects

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

Lists distinct defects linked to any attempt in the run (join `defect_result_links` →
`test_results` for the run). Each item includes public defect fields plus
`linkCountInRun` (links within this run only — links on other runs are excluded), `assigneeUlid` (the assignee's ULID, or `null` when unassigned), and `systemFieldValues` for severity/priority.
Returns `{ items: [...] }` (no pagination). Cross-tenant or unknown runs return
`404 not_found`. Works for open and closed runs.

## Detail

`GET /api/v1/defects/{defectUlid}`

Returns the full defect payload including vocabulary fields, people ULIDs, tags, milestone, duplicate-of reference, lifecycle timestamps, derived occurrence summary fields (`occurrenceCount`, `lastSeenAt`, `affectedCaseCount`), and embedded `customFieldValues` for every defect-scoped field visible in the project (stored value or materialized default, with option enrichment when applicable). The project defects list includes `customFieldValues` only when requested via `fields`.

## Custom field values

Defect-scoped definitions are managed under `GET/POST/PATCH/DELETE /api/v1/orgs/{orgUlid}/custom-fields` with `entity: defect`. Values are stored per `(field, defect)` pair.

### Replace all visible values

`PUT /api/v1/defects/{defectUlid}/custom-field-values`

Body: `{ "values": [ { "fieldUlid": "...", "value": ... }, ... ] }` — one entry per visible field. Required fields must be present in the write set. Emits `defect.updated` with `metadata.customFieldChanges` (not a separate `custom_field.*` event).

### Patch one field

`PATCH /api/v1/defects/{defectUlid}/custom-field-values/{fieldUlid}`

Body: `{ "value": ... }`. Clears the value when `null` is sent for nullable types; required fields reject empty clears with `422 validation_failed`. Returns the full embedded `customFieldValues` array for the defect.

## Update

`PATCH /api/v1/defects/{defectUlid}`

Partial update of `title`, `description`, `status`, `resolution`, `assigneeUlid`, `tags`, `milestoneUlid`, and `duplicateOfUlid`. Severity and priority are edited via the custom-field value endpoints, not this patch.

Status transitions follow a guided map (`open`/`in_progress` → `resolved`/`closed`; `resolved` → `closed`/`open`; `closed` → `open`). Illegal transitions return `422 validation_failed` with field path `status`. Same-status patches apply other fields without emitting a status event.

- Transitioning to `resolved` or `closed` requires a `resolution`.
- `resolution = duplicate` requires `duplicateOfUlid`; chains flatten to the canonical root on write.
- Reopening to `open` from `resolved` or `closed` clears `resolution`, `duplicateOfUlid`, and managed timestamps; emits `defect.reopened` instead of `defect.status_changed`.
- `duplicateOfUlid` must reference another defect in the **same project** when used.

Requires the defect write permission (held by `owner`/`admin`/`member`); a `viewer` receives `403 forbidden` before any write.

Returns `404 not_found` when the defect is outside the active organization.

## Delete

`DELETE /api/v1/defects/{defectUlid}`

Hard-deletes the defect. Returns `204` on success; subsequent `GET` returns `404 not_found`.

## Link to test results

Defects can be linked to immutable run attempts (`test_results` rows). Links are unique per `(defect, result)` pair; repeating the same request returns the existing link with `200`.

### Create or re-link

`POST /api/v1/defects/{defectUlid}/result-links`

Requires the defect write permission (held by `owner`/`admin`/`member`); a `viewer` receives `403 forbidden`.

| Field              | Required | Notes                                               |
| ------------------ | -------- | --------------------------------------------------- |
| `resultUlid`       | yes      | Result from a run in the same project as the defect |
| `stepSnapshotUlid` | no       | Frozen step snapshot belonging to that result       |

Returns `201` on first link, `200` when the pair already exists.

### List occurrences

`GET /api/v1/defects/{defectUlid}/result-links`

Cursor pagination (`limit`, `cursor`). Each item includes the link, linker, result status/`executedAt`, and run/test-case context.

### Unlink

`DELETE /api/v1/defects/{defectUlid}/result-links/{linkUlid}`

Requires the defect write permission (held by `owner`/`admin`/`member`); a `viewer` receives `403 forbidden`. Returns `204`. The link must belong to the referenced defect.

### Suggestions for a test case

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

Lists defects previously linked to any result of the test case, with per-case `occurrenceCount`, `lastSeenAt`, and `systemFieldValues` (severity/priority). Open/in-progress defects are ordered before resolved/closed ones.

## Affected test cases

`GET /api/v1/defects/{defectUlid}/affected-cases`

Cursor pagination (`limit`, `cursor`). Returns distinct test cases linked through result associations. Each item includes `testCaseUlid`, `testCaseTitle`, `caseNumber`, `displayId` (the case's repository reference, `{PROJECT_CODE}-{caseNumber}`), `occurrenceCount`, `lastLinkedAt`, `latestResult` (overall newest execution for the case, or `null` when never executed), and `verification`.

When the defect status is `resolved`, `verification` is one of:

| Value                  | Meaning                                                        |
| ---------------------- | -------------------------------------------------------------- |
| `verified`             | Latest result is `passed` with `executedAt` after `resolvedAt` |
| `failed_after_resolve` | Latest result is `failed` or `blocked` after `resolvedAt`      |
| `pending`              | No qualifying execution after resolution                       |

For non-resolved defects, `verification` is `null`. Returns `404 not_found` cross-tenant.

## Audit events

Defect mutations emit `defect.created`, `defect.updated`, `defect.status_changed`, `defect.reopened`, `defect.deleted`, `defect.linked`, and `defect.unlinked` on the organization activity feed (link events also appear on the run feed via `run_id`). Evidence and discussion mutations also emit `defect.attachment_added`, `defect.attachment_deleted`, `defect.commented`, `defect.comment_updated`, and `defect.comment_deleted`. Duplicate resolutions include `duplicateOfUlid` and a `D-<n>` label in `defect.status_changed` metadata. See [Audit events](https://docs.probara.net/en/developers/api/audit-events/).

### Per-defect history

`GET /api/v1/defects/{defectUlid}/audit-events`

Cursor pagination (`limit`, `cursor`) over events whose `entityType` is `defect` and whose `entityUlid` matches the defect. Response shape matches the run audit endpoint (`events`, `nextCursor`). Returns `404 not_found` when the defect is outside the active organization.

## Evidence attachments

Evidence files are stored under the organization R2 prefix. Any type is accepted except executables and scripts (`application/x-msdownload`, `application/x-msdos-program`, `application/x-sh`, `application/x-bat`, `application/x-msi`, `application/x-executable`, `application/vnd.microsoft.portable-executable`), checked against each part's declared content type; empty files are refused. PNG, JPEG, and WebP images are converted to WebP with a thumbnail and come back with `disposition: "inline"`; they are limited to **10 MiB** and 8192 px per side. Every other file is stored as sent, up to **32 MiB**, with `disposition: "attachment"`. Up to **20** `file` parts per upload request, and a defect holds at most **20** attachments: a request that would take it over is refused whole. Uploads charge the organization `defect_attachment` storage counter.

Viewers may list attachments; only **members and above** may upload or delete. Uploading and deleting require the attachment execute permission (held by `owner`/`admin`/`member`); a `viewer` receives `403 forbidden`.

### List

`GET /api/v1/defects/{defectUlid}/attachments`

Returns `{ attachments: [...] }`. Each file, in upload order (oldest first), has `kind` `file` and `ulid`, `mime`, dimensions, `byteSize`, `originalFilename`, `uploadedBy`, `url`, `thumbUrl`, and `createdAt`.

The list also holds the images of the defect's texts, its description and its paragraph custom field values. Each has `kind` `image` and a `field` that names the text it sits in: `{ "kind": "description" }`, or `{ "kind": "custom_field", "fieldUlid": "01J...", "name": "..." }`. An image leaves the list when it leaves its text.

### Upload

`POST /api/v1/defects/{defectUlid}/attachments`

`multipart/form-data` with one or more `file` parts. Returns `201` with `{ attachments: [...] }`. A blocked type, an oversize file or image, an empty file, or too many files returns `422 validation_failed`. Emits `defect.attachment_added`.

This is a direct upload, not a stage, and it ignores the [`Idempotency-Key`](https://docs.probara.net/en/developers/api/idempotency/) header. A retried upload can store the same files twice: after a timeout or another ambiguous failure, list the defect's attachments before you retry. Keep the whole request body at most 64 MiB (`ATTACHMENT_UPLOAD_REQUEST_RECOMMENDED_MAX_BYTES`) and split larger batches across requests. Cloudflare refuses a request body over 100 MB with `413` before it reaches the API, whatever the per-file and per-request limits allow; that `413` does not carry the API error envelope.

### Delete

`DELETE /api/v1/defects/{defectUlid}/attachments/{attachmentUlid}`

Returns `204`. Removes the row, the stored file and its thumbnail when it has one (PNG, JPEG, and WebP images), and refunds storage bytes. Emits `defect.attachment_deleted`. An attachment ULID belonging to another defect returns `404 not_found`.

Defect deletion removes all evidence objects and refunds summed bytes before the cascade.

## Discussion comments

Comments are rich text (Markdown in `body` or a document in `bodyDocument`, at most **5000** characters of text) and support one level of threaded replies, pinning, and `@mention` resolution. The defect comment surface is structurally identical to [test case comments](https://docs.probara.net/en/developers/api/test-case-comments/) — see that page for the full reference (reply semantics, pinning rules, mention resolution, response shape, and cascade delete). This section lists only the defect-scoped paths and audit actions.

Viewers may list comments; only **members and above** may create, edit, delete, or pin/unpin. Listing is cursor-paginated over **top-level** comments (`parentUlid` absent), defaulting to oldest-first; each item's inline `replies[]` stays chronological regardless of sort.

### List

`GET /api/v1/defects/{defectUlid}/comments`

Query: `limit` (default `50`, max `200`), `cursor`, `sort` (`oldest` | `newest`, default `oldest`). Each item carries `parentUlid`, `pinnedAt`, `pinnedBy`, `replyCount`, `mentions`, and inline `replies[]`; the response's `pinned[]` sibling holds the complete pinned set for the defect. Replies are **not** enumerated in the flat `items[]` — see [test case comments](https://docs.probara.net/en/developers/api/test-case-comments/) for the full response shape and the replies caveat.

### Create

`POST /api/v1/defects/{defectUlid}/comments`

| Field        | Required | Notes                                                                      |
| ------------ | -------- | -------------------------------------------------------------------------- |
| `body`       | yes      | Trimmed text, 1–5000 characters                                            |
| `parentUlid` | no       | Replies to an existing top-level comment on the same defect (depth-1 only) |

Returns `201`. Emits `defect.commented` with a 140-character excerpt.

### Edit

`PATCH /api/v1/defects/{defectUlid}/comments/{commentUlid}`

| Field  | Required | Notes                     |
| ------ | -------- | ------------------------- |
| `body` | yes      | Same validation as create |

**Author only** — admins cannot edit another member's comment (`403 forbidden`). Sets `editedAt` on success and re-resolves mentions. Emits `defect.comment_updated`.

### Delete

`DELETE /api/v1/defects/{defectUlid}/comments/{commentUlid}`

Returns `204`. The **author** may delete their own comment. **Organization admins** may delete any comment. Other members receive `403`. Deleting a top-level comment cascades to its replies, purging their attachments. Emits `defect.comment_deleted` (once for the deleted comment, plus once per cascaded reply).

### Pin / unpin

`POST` / `DELETE /api/v1/defects/{defectUlid}/comments/{commentUlid}/pin`

Any member or above may pin or unpin any **top-level** comment, regardless of authorship; viewers receive `403`. Pinning a reply returns `422 validation_failed`. There is no cap on pinned comments per defect. Emits `defect.comment_pinned` / `defect.comment_unpinned`.
